From 0e8342278a30b58e2b4f8cbb6f50df3c55318e05 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 16:24:17 -0600 Subject: [PATCH 1/2] feat: remove specifier dependency. --- README.md | 1 - babel.config.json | 2 +- package-lock.json | 324 +-------------------------------------------- package.json | 3 +- src/module.ts | 2 +- src/specifier.ts | 325 ++++++++++++++++++++++++++++++++++++++++++++++ src/utils/lang.ts | 5 +- test/specifier.ts | 143 ++++++++++++++++++++ 8 files changed, 475 insertions(+), 330 deletions(-) create mode 100644 src/specifier.ts create mode 100644 test/specifier.ts diff --git a/README.md b/README.md index 359a0af..e04ee5a 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,6 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, ## Roadmap -- Remove `@knighted/specifier` and avoid double parsing. - Emit source maps and clearer diagnostics for transform choices. - Broaden fixtures covering live-binding and top-level await edge cases across Node versions. - Benchmark scope analysis choices: compare `periscopic`, `scope-analyzer`, and `eslint-scope` on fixtures and pick the final adapter. diff --git a/babel.config.json b/babel.config.json index dfd1c0e..bf97f1d 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,5 +1,5 @@ { - "targets": "node >= 20.11.0", + "targets": "node >= 22.21.1", "presets": [ [ "@babel/preset-env", diff --git a/package-lock.json b/package-lock.json index ac530e2..40af7d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "@knighted/module", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "license": "MIT", "dependencies": { - "@knighted/specifier": "^2.0.9", "magic-string": "^0.30.21", "node-module-type": "^1.0.4", "oxc-parser": "^0.105.0", @@ -2372,300 +2371,6 @@ "node": ">=12.20.0" } }, - "node_modules/@knighted/specifier": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@knighted/specifier/-/specifier-2.0.9.tgz", - "integrity": "sha512-r+g/NVHDKSUu4gQh72x6A8l3Qh3X3L0qpF9hd+SYrawfmzyaTyXH02n8lpl1PpaGwnmcJOU2q95sI0m/Ifppxg==", - "license": "MIT", - "dependencies": { - "@knighted/walk": "^1.0.1", - "magic-string": "^0.30.21", - "oxc-parser": "^0.99.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.99.0.tgz", - "integrity": "sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.99.0.tgz", - "integrity": "sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.99.0.tgz", - "integrity": "sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.99.0.tgz", - "integrity": "sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.99.0.tgz", - "integrity": "sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.99.0.tgz", - "integrity": "sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.99.0.tgz", - "integrity": "sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.99.0.tgz", - "integrity": "sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.99.0.tgz", - "integrity": "sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.99.0.tgz", - "integrity": "sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.99.0.tgz", - "integrity": "sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.99.0.tgz", - "integrity": "sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.99.0.tgz", - "integrity": "sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.99.0.tgz", - "integrity": "sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@knighted/specifier/node_modules/@oxc-project/types": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.99.0.tgz", - "integrity": "sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@knighted/specifier/node_modules/oxc-parser": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.99.0.tgz", - "integrity": "sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.99.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm64": "0.99.0", - "@oxc-parser/binding-darwin-arm64": "0.99.0", - "@oxc-parser/binding-darwin-x64": "0.99.0", - "@oxc-parser/binding-freebsd-x64": "0.99.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.99.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.99.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.99.0", - "@oxc-parser/binding-linux-arm64-musl": "0.99.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.99.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.99.0", - "@oxc-parser/binding-linux-x64-gnu": "0.99.0", - "@oxc-parser/binding-linux-x64-musl": "0.99.0", - "@oxc-parser/binding-wasm32-wasi": "0.99.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.99.0", - "@oxc-parser/binding-win32-x64-msvc": "0.99.0" - } - }, - "node_modules/@knighted/walk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@knighted/walk/-/walk-1.0.1.tgz", - "integrity": "sha512-A/JHCHRK3e0gjqZcjoonZrdfQbMTrRg94ul5C0paYfsglPtJ9HdGm8iqZlgR0syzVBBaRdHc5HjgMhglZXta2w==", - "license": "MIT", - "dependencies": { - "estree-walker": "^3.0.3" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "oxc-parser": ">=0.61.2" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", @@ -2758,22 +2463,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.99.0.tgz", - "integrity": "sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { "version": "0.105.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.105.0.tgz", @@ -3692,15 +3381,6 @@ "node": ">=6" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", diff --git a/package.json b/package.json index a6db513..c14adbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/module", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "description": "Transforms differences between ES modules and CommonJS.", "type": "module", "main": "dist/module.js", @@ -78,7 +78,6 @@ "typescript": "^5.9.3" }, "dependencies": { - "@knighted/specifier": "^2.0.9", "magic-string": "^0.30.21", "node-module-type": "^1.0.4", "oxc-parser": "^0.105.0", diff --git a/src/module.ts b/src/module.ts index 84c5ace..c25f466 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path' import { readFile, writeFile } from 'node:fs/promises' -import { specifier } from '@knighted/specifier' +import { specifier } from './specifier.js' import { parse } from '#parse' import { format } from '#format' diff --git a/src/specifier.ts b/src/specifier.ts new file mode 100644 index 0000000..fce771b --- /dev/null +++ b/src/specifier.ts @@ -0,0 +1,325 @@ +import { resolve } from 'node:path' +import { stat, readFile } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import MagicString from 'magic-string' +import type { + ParserOptions, + ParseResult, + Node, + StringLiteral, + TemplateLiteral, + BinaryExpression, + NewExpression, + ImportDeclaration, + ExportNamedDeclaration, + ExportAllDeclaration, + TSImportType, + ImportExpression, + CallExpression, +} from 'oxc-parser' +import { parseSync } from 'oxc-parser' + +import { walk } from '#walk' + +type Spec = { + type: 'StringLiteral' | 'TemplateLiteral' | 'BinaryExpression' | 'NewExpression' + node: StringLiteral | TemplateLiteral | BinaryExpression | NewExpression + parent: + | CallExpression + | ImportDeclaration + | ExportNamedDeclaration + | ExportAllDeclaration + | ImportExpression + | TSImportType + start: number + end: number + value: string +} + +type Callback = (spec: Spec) => string | void + +type SpecifierApi = { + update: (filename: string, callback: Callback) => Promise + updateSrc: ( + code: string, + lang: ParserOptions['lang'], + callback: Callback, + ) => Promise +} + +const isStringLiteral = (node: Node): node is StringLiteral => { + return node.type === 'Literal' && typeof node.value === 'string' +} + +const isBinaryExpression = (node: Node): node is BinaryExpression => { + // Distinguish between BinaryExpression and PrivateInExpression + return node.type === 'BinaryExpression' && node.operator !== 'in' +} + +const isCallExpression = (node: Node): node is CallExpression => { + return node.type === 'CallExpression' && node.callee !== undefined +} + +const formatSpecifiers = async (src: string, ast: ParseResult, cb: Callback) => { + const code = new MagicString(src) + const formatExpression = (expression: ImportExpression | CallExpression) => { + const node = isCallExpression(expression) + ? expression.arguments[0] + : expression.source + const { type } = node + + switch (type) { + case 'Literal': { + if (isStringLiteral(node)) { + const { start, end, value } = node + const updated = cb({ + type: 'StringLiteral', + parent: expression, + node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + } + break + } + case 'TemplateLiteral': { + const { start, end } = node + const value = src.slice(start + 1, end - 1) + const updated = cb({ + type: 'TemplateLiteral', + parent: expression, + node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + break + } + case 'BinaryExpression': { + if (isBinaryExpression(node)) { + const { start, end } = node + const value = src.slice(start, end) + const updated = cb({ + type: 'BinaryExpression', + parent: expression, + node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start, end, updated) + } + } + break + } + case 'NewExpression': { + if (node.callee.type === 'Identifier' && node.callee.name === 'String') { + const { start, end } = node + const value = src.slice(start, end) + const updated = cb({ + type: 'NewExpression', + parent: expression, + node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start, end, updated) + } + } + break + } + } + } + + await walk(ast.program, { + enter(node) { + if (node.type === 'ExpressionStatement') { + const { expression } = node + + if (expression.type === 'ImportExpression') { + formatExpression(expression) + } + } + + if (node.type === 'CallExpression') { + // Handle require(), require.resolve(), import.meta.resolve() + if ( + (node.callee.type === 'Identifier' && node.callee.name === 'require') || + (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'require' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'resolve') || + (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'MetaProperty' && + node.callee.object.meta.name === 'import' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'resolve') + ) { + formatExpression(node) + } + } + + if (node.type === 'ArrowFunctionExpression') { + const { body } = node + + if (body.type === 'ImportExpression') { + formatExpression(body) + } + + if ( + body.type === 'CallExpression' && + body.callee.type === 'Identifier' && + body.callee.name === 'require' + ) { + formatExpression(body) + } + } + + if ( + node.type === 'MemberExpression' && + node.object.type === 'ImportExpression' && + node.property.type === 'Identifier' && + node.property.name === 'then' + ) { + formatExpression(node.object) + } + + if (node.type === 'TSImportType') { + const source = (node as any).source + + if (source && isStringLiteral(source)) { + const { start, end, value } = source + const updated = cb({ + type: 'StringLiteral', + node: source, + parent: node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + } + } + + if (node.type === 'ImportDeclaration') { + const { source } = node + const { start, end, value } = source + const updated = cb({ + type: 'StringLiteral', + node: source, + parent: node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + } + + if (node.type === 'ExportNamedDeclaration' && node.source) { + const { source } = node + const { start, end, value } = source + const updated = cb({ + type: 'StringLiteral', + node: source, + parent: node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + } + + if (node.type === 'ExportAllDeclaration') { + const { source } = node + const { start, end, value } = source + const updated = cb({ + type: 'StringLiteral', + node: source, + parent: node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + } + }, + }) + + return code.toString() +} + +const isValidFilename = async (filename: string) => { + let stats: Stats + + try { + stats = await stat(filename) + } catch { + return false + } + + if (!stats.isFile()) { + return false + } + + return true +} + +const specifier = { + async update(path: string, callback: Callback) { + const filename = resolve(path) + const validated = await isValidFilename(filename) + + if (!validated) { + throw new Error(`The provided path ${path} does not resolve to a file on disk.`) + } + + const src = (await readFile(filename)).toString() + const ast = parseSync(filename, src) + + return await formatSpecifiers(src, ast, callback) + }, + + async updateSrc(src: string, lang: ParserOptions['lang'], callback: Callback) { + const filename = + lang === 'ts' + ? 'file.ts' + : lang === 'tsx' + ? 'file.tsx' + : lang === 'js' + ? 'file.js' + : 'file.jsx' + const ast = parseSync(filename, src) + + return await formatSpecifiers(src, ast, callback) + }, +} satisfies SpecifierApi + +export { specifier } +export type { Spec, Callback } diff --git a/src/utils/lang.ts b/src/utils/lang.ts index 6803a48..31330e8 100644 --- a/src/utils/lang.ts +++ b/src/utils/lang.ts @@ -1,9 +1,8 @@ import { extname } from 'node:path' -import type { Specifier } from '@knighted/specifier' +import type { ParserOptions } from 'oxc-parser' // Determine language from filename extension for specifier rewrite. -type UpdateSrcLang = Parameters[1] -const getLangFromExt = (filename: string): UpdateSrcLang => { +const getLangFromExt = (filename: string): ParserOptions['lang'] | undefined => { const ext = extname(filename).toLowerCase() if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { diff --git a/test/specifier.ts b/test/specifier.ts new file mode 100644 index 0000000..b70e9f1 --- /dev/null +++ b/test/specifier.ts @@ -0,0 +1,143 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { join, resolve } from 'node:path' + +import { specifier } from '../src/specifier.js' + +const fixtures = resolve(import.meta.dirname, 'fixtures', 'specifier') + +const rewriteRelative = (value: string, suffix: string) => { + // Collapse BinaryExpression or NewExpression text to detect relative ids + const collapsed = value.replace(/['"`+)\s]|new String\(/g, '') + if (!/^(?:\.|\.\.)\//.test(collapsed)) return + return value.replace(/\.([mc]?js|[mc]?ts)/g, `${suffix}.$1`) +} + +describe('specifier.update', () => { + it('rewrites imports, import.meta.resolve, and dynamic imports in esm files', async () => { + const result = await specifier.update( + join(fixtures, 'specifier.mjs'), + ({ value }) => { + return rewriteRelative(value, '.out') + }, + ) + + assert.ok(result.includes('./file.out.mts')) + assert.ok(result.includes('./file.out.cts')) + assert.ok(result.includes('./file.out.ts')) + assert.ok(result.includes("import.meta.resolve('./file.out.cjs')")) + assert.ok(result.includes("import.meta.resolve('./file.out.js')")) + assert.ok(result.includes('./${foo}${bar}${baz}.out.cjs')) + // non-relative should remain untouched + assert.ok(result.includes("new String('not-relative.js')")) + }) + + it('rewrites require and import usage in cjs files', async () => { + const result = await specifier.update( + join(fixtures, 'specifier.cjs'), + ({ value }) => { + return rewriteRelative(value, '.esm') + }, + ) + + assert.ok(result.includes("require('./file.esm.cjs')")) + assert.ok(result.includes("require.resolve('./file.esm.cjs')")) + assert.ok(result.includes("import('./file.esm.cjs')")) + }) + + it('throws for missing files or directories', async () => { + await assert.rejects(() => specifier.update(join(fixtures, 'missing.js'), () => {}), { + message: /does not resolve to a file on disk/, + }) + + await assert.rejects(() => specifier.update(fixtures, () => {}), { + message: /does not resolve to a file on disk/, + }) + }) +}) + +describe('specifier.updateSrc', () => { + it('rewrites mixed expression shapes', async () => { + const source = + ` + import './side.js' + import('./dynamic.js').then(() => {}) + import(` + + '`' + + `./tmpl/\${name}.js` + + '`' + + `) + const bin = require('./binary' + '/expression.js') + const newReq = require(new String('./new.js')) + import(new String('./new-import.js')) + const arrowImport = () => import('./arrow.js') + const arrowRequire = () => require('./arrow-req.js') + ` + + const updated = await specifier.updateSrc(source, 'js', ({ value }) => { + return rewriteRelative(value, '.mjs') + }) + + assert.ok(updated.includes("import './side.mjs.js'")) + assert.ok(updated.includes("import('./dynamic.mjs.js').then")) + assert.ok(updated.includes('import(`./tmpl/${name}.mjs.js`)')) + assert.ok(updated.includes("require('./binary' + '/expression.mjs.js')")) + assert.ok(updated.includes("require(new String('./new.mjs.js'))")) + assert.ok(updated.includes("import(new String('./new-import.mjs.js'))")) + assert.ok(updated.includes("() => import('./arrow.mjs.js')")) + assert.ok(updated.includes("() => require('./arrow-req.mjs.js')")) + assert.ok(!updated.includes('./new-import.js')) + assert.ok(!updated.includes("'/expression.js'")) + }) + + it('rewrites TS import type literals', async () => { + const tsSource = ` + type Foo = import('./types.js').Foo + const value: import('./types.js').Bar = {} + ` + + const updated = await specifier.updateSrc(tsSource, 'ts', ({ value }) => { + return rewriteRelative(value, '.mjs') + }) + + assert.equal([...updated.matchAll(/types\.mjs\.js/g)].length, 2) + }) + + it('rewrites import().then and export declarations', async () => { + const source = + ` + import('./dyn.js').then(() => {}) + import(` + + '`' + + `./tmpl/\${name}.js` + + '`' + + `) + export { foo } from './exported.js' + export * from './star.js' + ` + + const updated = await specifier.updateSrc(source, 'js', ({ value }) => { + return rewriteRelative(value, '.esm') + }) + + assert.ok(updated.includes("import('./dyn.esm.js').then")) + assert.ok(updated.includes('./tmpl/${name}.esm.js')) + assert.ok(updated.includes("export { foo } from './exported.esm.js'")) + assert.ok(updated.includes("export * from './star.esm.js'")) + }) + + it('rewrites jsx and tsx input', async () => { + const jsxSource = "import './jsx.js'\nconst el =
" + const tsxSource = "import './tsx.js'\nconst el: JSX.Element =
" + + const jsxUpdated = await specifier.updateSrc(jsxSource, 'jsx', ({ value }) => { + return rewriteRelative(value, '.mjs') + }) + const tsxUpdated = await specifier.updateSrc(tsxSource, 'tsx', ({ value }) => { + return rewriteRelative(value, '.mjs') + }) + + assert.ok(jsxUpdated.includes("import './jsx.mjs.js'")) + assert.ok(tsxUpdated.includes("import './tsx.mjs.js'")) + }) +}) From b6d2b6abfc6f19dc06120a459088967190c196ba Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 16:28:32 -0600 Subject: [PATCH 2/2] chore: fix types. --- .github/workflows/ci.yml | 2 ++ .husky/pre-push | 1 + src/utils.ts | 23 ----------------------- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1a605b..01a8adf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: path: npm-debug.log - name: Lint run: npm run lint + - name: Type Check + run: npm run check-types - name: Test run: npm test - name: Report Coverage diff --git a/.husky/pre-push b/.husky/pre-push index c05253c..458c902 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,5 @@ #!/usr/bin/env sh npm run prettier:check npm run lint +npm run check-types npm test diff --git a/src/utils.ts b/src/utils.ts index 679daf1..ea69f55 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,33 +1,11 @@ -import { extname } from 'node:path' import { ancestorWalk } from './walk.js' import type { Node } from 'oxc-parser' -import type { Specifier } from '@knighted/specifier' import type { IdentMeta, SpannedNode, Scope, CjsExport } from './types.js' import { identifier } from './helpers/identifier.js' import { scopeNodes } from './utils/scopeNodes.js' -type UpdateSrcLang = Parameters[1] -const getLangFromExt = (filename: string): UpdateSrcLang => { - const ext = extname(filename).toLowerCase() - - if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { - return 'js' - } - - if (ext === '.ts' || ext === '.mts' || ext === '.cts') { - return 'ts' - } - - if (ext === '.tsx') { - return 'tsx' - } - - if (ext === '.jsx') { - return 'jsx' - } -} const isValidUrl = (url: string) => { try { new URL(url) @@ -337,7 +315,6 @@ const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) => } export { - getLangFromExt, isValidUrl, collectScopeIdentifiers, collectModuleIdentifiers,