From 57d935d4f04b6674f29c930ef8f1aaad9b9270a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 27 Jul 2026 05:17:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(compile/hir):=20five=20Vercel=20CLI=20corpu?= =?UTF-8?q?s=20blockers=20=E2=80=94=20CJS=20comma-first=20requires,=20body?= =?UTF-8?q?-local=20enums,=20D005/T002=20false=20positives,=20.node=20diag?= =?UTF-8?q?nostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by compiling the Vercel CLI (real-apps/vercel, 1137 TS files / 168k LOC) end to end. - cjs_wrap: a comma-first `var a = require(x)\n , b = require(y);` list matched only declarator #0 (the alias regex ends at `(?m)$`), and blanking that range left `, b = ...` at statement position -> TS1109. Same failure shape as #845, reached via a comma instead of a member access. Multi-declarator statements are now skipped entirely, falling back to the IIFE-bound `require`. Hit by ajv 6.x. - hir: `enum` inside a function body is valid TS but bailed. Member access is materialized from `ctx.lookup_enum`, but codegen resolves `Expr::EnumMember` against `Module::enums`, which body-local decls could not reach. Added `LoweringContext::pending_body_enums`, drained in `lower_module_full`. A same-named body-local enum with different members still diagnoses rather than silently reusing the first registration. - deps/D005: the check was a per-line `!line.contains("import('")` test, so it keyed off formatting, not the argument — a prettier-wrapped multi-line `await import('./x')` was flagged while the same call on one line was not. It also scanned `import(` inside string literals. Now resolved across newlines on a comment/string-masked copy. - deps/T002: honor `types`/`typings` in package.json and a `types` condition in `exports`, not just three hardcoded paths. date-fns, tldts and @inquirer/* were all reported as untyped. - collect_modules: a `.node` addon reached as a module reported "stream did not contain valid UTF-8". The existing refusal only covers packages that resolved to a compilePackages root, missing the napi-rs sidecar layout. The read is now guarded directly. Corpus effect: `perry check --check-deps` goes from 4 errors / 20 T002 warnings to 0 / 5, and the 5 remaining are genuine. `perry compile` now reaches the native-addon boundary with ajv unpatched and the enum unhoisted. Tests: 10 new unit tests (cjs_wrap + deps) and test-files/test_gap_enum_in_function_body.ts. perry bin suite 745/745 green; the one perry-hir failure (logical_property_assignment_short_circuits_the_ store_4586) reproduces on origin/main with these changes stashed. --- CLAUDE.md | 2 +- Cargo.lock | 150 +++---- Cargo.toml | 2 +- changelog.d/6885-vercel-cli-corpus-compat.md | 72 ++++ crates/perry-hir/src/lower/context.rs | 1 + crates/perry-hir/src/lower/lower_module_fn.rs | 12 + .../perry-hir/src/lower/lowering_context.rs | 7 + crates/perry-hir/src/lower_decl/body_stmt.rs | 52 ++- crates/perry/src/commands/compile.rs | 5 +- .../compile/cjs_wrap/extract_requires.rs | 35 ++ .../src/commands/compile/cjs_wrap/mod.rs | 63 ++- .../src/commands/compile/collect_modules.rs | 4 +- .../compile/collect_modules/native_addon.rs | 31 ++ .../commands/compile/optimized_libs/tests.rs | 10 +- crates/perry/src/commands/deps.rs | 366 +++++++++++++++++- .../perry/src/commands/install/lifecycle.rs | 6 + crates/perry/src/main.rs | 2 + crates/perry/src/test_env_lock.rs | 30 ++ test-files/test_gap_enum_in_function_body.ts | 86 ++++ 19 files changed, 822 insertions(+), 114 deletions(-) create mode 100644 changelog.d/6885-vercel-cli-corpus-compat.md create mode 100644 crates/perry/src/test_env_lock.rs create mode 100644 test-files/test_gap_enum_in_function_body.ts diff --git a/CLAUDE.md b/CLAUDE.md index 87b1cf5d1b..01daae69c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1264 +**Current Version:** 0.5.1265 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 3b865a620d..e9eccd9953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5477,7 +5477,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "base64", @@ -5536,14 +5536,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "cc", "libc", @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "log", @@ -5565,7 +5565,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-hir", @@ -5573,7 +5573,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-hir", @@ -5581,7 +5581,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-dispatch", @@ -5590,7 +5590,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-hir", @@ -5598,7 +5598,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "base64", @@ -5610,7 +5610,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-hir", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "async-trait", @@ -5647,14 +5647,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "serde", "serde_json", @@ -5662,7 +5662,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1264" +version = "0.5.1265" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "clap", @@ -5688,7 +5688,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "block2", "objc2", @@ -5698,7 +5698,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "argon2", "perry-ffi", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "reqwest", @@ -5715,7 +5715,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bcrypt", "perry-ffi", @@ -5723,7 +5723,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "rusqlite", @@ -5731,7 +5731,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "scraper", @@ -5739,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "perry-runtime", @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "chrono", "cron", @@ -5757,7 +5757,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "chrono", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "rust_decimal", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "serde_json", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "perry-runtime", @@ -5797,14 +5797,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bytes", "http-body-util", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bytes", "lazy_static", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bytes", "lazy_static", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bytes", "h2", @@ -5872,7 +5872,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "lazy_static", "perry-ffi", @@ -5882,7 +5882,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "jsonwebtoken", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "lru", "perry-ffi", @@ -5901,7 +5901,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "chrono", "perry-ffi", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bson", "futures-util", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "chrono", "perry-ffi", @@ -5931,7 +5931,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "nanoid", "perry-ffi", @@ -5940,7 +5940,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "bytes", "perry-ffi", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "lettre", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "printpdf", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "sqlx", @@ -5980,7 +5980,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "governor", "perry-ffi", @@ -5988,7 +5988,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "fast_image_resize", "image", @@ -5998,14 +5998,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "lazy_static", "perry-ffi", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "uuid", @@ -6022,7 +6022,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ffi", "regex", @@ -6032,7 +6032,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "futures-util", "lazy_static", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "brotli", "flate2", @@ -6054,7 +6054,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "dashmap", "once_cell", @@ -6063,7 +6063,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-api-manifest", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-diagnostics", @@ -6092,7 +6092,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "base64", @@ -6132,14 +6132,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6232,14 +6232,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "perry-hir", @@ -6248,14 +6248,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "itoa", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "rand 0.8.6", "serde", @@ -6282,7 +6282,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "block2", @@ -6321,7 +6321,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "block2", @@ -6336,7 +6336,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1264" +version = "0.5.1265" [[package]] name = "perry-ui-test" @@ -6344,11 +6344,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1264" +version = "0.5.1265" [[package]] name = "perry-ui-tvos" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "block2", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "block2", @@ -6380,7 +6380,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "block2", "libc", @@ -6393,7 +6393,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "base64", "libc", @@ -6410,14 +6410,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "anyhow", "base64", @@ -6433,7 +6433,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1264" +version = "0.5.1265" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index cd0b23e679..88408d9d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -286,7 +286,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1264" +version = "0.5.1265" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/6885-vercel-cli-corpus-compat.md b/changelog.d/6885-vercel-cli-corpus-compat.md new file mode 100644 index 0000000000..00980fa811 --- /dev/null +++ b/changelog.d/6885-vercel-cli-corpus-compat.md @@ -0,0 +1,72 @@ +### Fixed + +- **CJS→ESM wrap: comma-first `require` declarator lists no longer produce a + parse error.** The alias regex ends at `(?m)$`, so in the pre-ES6 style + + ```js + var compileSchema = require('./compile') + , resolve = require('./compile/resolve') + , Cache = require('./cache'); + ``` + + only declarator #0 matched. Blanking that range left `, resolve = …` at + statement position — `TS1109 (Expression expected)`, the same failure shape + as the `.EventEmitter;` case in #845, reached via a comma instead of a member + access. A multi-declarator statement is now skipped entirely: nothing is + blanked, the body keeps the valid original, and the IIFE-bound `require` + resolves each specifier at runtime (the same fallback the wrap already takes + when it refuses an adoption). Hit by ajv 6.x (`lib/ajv.js`, + `lib/compile/index.js`). + +- **`enum` declared inside a function body now compiles.** Valid TypeScript + that Perry rejected with "declare it at module scope". Enum member access is + materialized from `ctx.lookup_enum`, but codegen resolves `Expr::EnumMember` + against `Module::enums`, which body-local declarations had no route to. They + are now registered at their declaration site and drained into the module via + `LoweringContext::pending_body_enums` once every function is lowered. Two + same-named body-local enums with *different* members still raise a + diagnostic rather than silently reusing the first registration. + +- **`D005` (dynamic import) no longer fires on static specifiers.** The check + was a per-line `!line.contains("import('")` test, so it was sensitive to + formatting rather than to the argument: a prettier-wrapped + `await import(\n './x'\n)` was reported as a variable path while the + identical single-line call was not — both shapes occur in the same file in + the Vercel CLI. It also scanned `import(` inside string literals, flagging a + loader script that is built as a template literal and written to disk for a + *child Node process*. The specifier is now resolved across newlines on a + comment/string-masked copy of the source. + +- **`T002` (missing types) now honors the canonical `types`/`typings` field.** + The probe checked three hardcoded paths (`index.d.ts`, `dist/index.d.ts`, + `types/`) and ignored package.json entirely, so `date-fns` + (`./typings.d.ts`), `tldts` (`dist/types/index.d.ts`) and `@inquirer/*` + (`./dist/cjs/types/index.d.ts`) were all reported as untyped. Resolution now + checks `types`/`typings` (including the extensionless form), a `types` + condition anywhere in an `exports` map, then the legacy layouts, then a + bounded `.d.ts` scan of the package root and `dist/`. + +- **A `.node` addon reached as a module now reports what it is.** Both module + read paths call `fs::read_to_string`, so a compiled N-API addon surfaced as + `stream did not contain valid UTF-8`, naming neither the constraint nor the + package. `refuse_compile_package_native_addon` only covers addons whose + package root resolved to a `compilePackages` entry, which misses the napi-rs + sidecar layout (`@napi-rs/keyring` → `@napi-rs/keyring-darwin-arm64`, + containing nothing but the `.node` file and a package.json). The read itself + is now guarded, so the diagnostic is the same either way. + +### Internal + +- **Test env guard is now process-wide.** `optimized_libs::tests` swaps `PATH` + for a fake, `sh`-less directory behind a module-private mutex, but + `install::lifecycle`'s `run_lifecycle_executes_script` reads `PATH` (via + `augment_path`) to resolve `sh` and did not share that lock — so it could + spawn against the fake PATH and fail with `No such file or directory`. The + guard moved to `crate::test_env_lock` and both sides take it. Latent before + this branch; the added tests shifted scheduling enough to surface it. + +All five were found by compiling the Vercel CLI (`real-apps/vercel`, +1137 TS files / 168k LOC) end to end. On that corpus `perry check --check-deps` +goes from 4 errors / 20 `T002` warnings to 0 errors / 5 warnings, and the five +remaining are genuine — four are packages the Vercel repo itself hand-writes +ambient declarations for. diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 06cab8b7e4..74b3c0b5b0 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -55,6 +55,7 @@ impl LoweringContext { class_native_extends: Vec::new(), class_field_types: Vec::new(), enums: Vec::new(), + pending_body_enums: Vec::new(), interfaces: Vec::new(), type_aliases: Vec::new(), immutable_locals: HashSet::new(), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 735162be26..c1c903484c 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -1185,5 +1185,17 @@ pub fn lower_module_full( // #806 mixin harness (bare-factory section). infer_dynamic_extends_names(&mut module); + // Attach enums declared inside function bodies. They were registered in + // `ctx.enums` at their declaration site (so the name resolves) but had no + // route to `Module::enums`, which is what codegen consults to resolve + // `Expr::EnumMember`. Drained here, after every function body has been + // lowered. Module-scope enums are already in `module.enums`, so skip any + // name that is present to avoid a duplicate entry. + for en in std::mem::take(&mut ctx.pending_body_enums) { + if !module.enums.iter().any(|e| e.name == en.name) { + module.enums.push(en); + } + } + Ok((module, ctx.next_class_id)) } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 91fa1143ea..26f1db2818 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -160,6 +160,13 @@ pub struct LoweringContext { pub(crate) class_field_types: Vec<(String, Vec<(String, Type)>)>, /// Enums: name -> (id, members with values) pub(crate) enums: Vec<(String, EnumId, Vec<(String, EnumValue)>)>, + /// Enums declared inside a FUNCTION BODY, awaiting attachment to the + /// module. `lower_body_stmt` has no `&mut Module`, but codegen resolves + /// `Expr::EnumMember` against `Module::enums` — so body-local enums are + /// parked here and drained in `lower_module_full` once every function has + /// been lowered. Registration in `enums` above is what makes the *name* + /// resolve; this is what makes it survive to codegen. + pub(crate) pending_body_enums: Vec, /// Interfaces: name -> id pub(crate) interfaces: Vec<(String, InterfaceId)>, /// Type aliases: name -> (id, type_params, aliased_type) diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 7a88622609..6644e34bee 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1966,15 +1966,51 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { } - // Body-local enum / namespace are valid TS but Perry only registers them - // at module scope (see lower.rs::lower_module). Silently dropping them - // here produced runtime ReferenceErrors at the use site instead of a - // compile diagnostic — fail loud so the user knows to hoist the decl. + // Body-local enum. Enum accesses never need a runtime object: both the + // named (`E.M`) and computed (`E[k]`) member paths are materialized + // inline from `ctx.lookup_enum` (see lower/expr_member.rs), so + // registering the declaration is all a body-local enum needs. Only the + // registration was module-scope-only, which is why this used to bail. + // + // The one real hazard is a name collision: registration is by name, and + // `lower_enum_decl` REUSES an existing registration's members when the + // name is already taken. Two same-named enums with different members + // (e.g. a local `enum Section` in two functions) would silently take the + // first one's values. Detect that and keep the original hoist-it + // diagnostic rather than miscompile. ast::Stmt::Decl(ast::Decl::TsEnum(enum_decl)) => { - crate::lower_bail!( - enum_decl.span, - "enum declared inside a function body is not supported; declare it at module scope" - ); + let name = enum_decl.id.sym.to_string(); + let members = compute_enum_members(enum_decl); + if let Some((_, existing)) = ctx.lookup_enum(&name) { + let same_value = |a: &EnumValue, b: &EnumValue| match (a, b) { + (EnumValue::Number(x), EnumValue::Number(y)) => x == y, + (EnumValue::String(x), EnumValue::String(y)) => x == y, + _ => false, + }; + let identical = existing.len() == members.len() + && existing + .iter() + .zip(members.iter()) + .all(|((en, ev), m)| *en == m.name && same_value(ev, &m.value)); + if !identical { + crate::lower_bail!( + enum_decl.span, + "enum `{}` conflicts with another enum of the same name; \ + declare it at module scope under a unique name", + name + ); + } + } + let lowered = lower_enum_decl(ctx, enum_decl, false)?; + // Only park it once — a function lowered more than once (or a + // second identical declaration) must not duplicate the entry. + if !ctx + .pending_body_enums + .iter() + .any(|e| e.name == lowered.name) + { + ctx.pending_body_enums.push(lowered); + } } ast::Stmt::Decl(ast::Decl::TsModule(ts_module)) => { crate::lower_bail!( diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index fa6ddcb5cc..1dee0a0a8c 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -22,7 +22,10 @@ mod bootstrap; mod build_cache; mod bundle_apple; mod bundle_ios; -mod cjs_wrap; +// `pub(crate)` so `commands::deps` can reuse `cjs_wrap::detect`'s +// comment/string masker for its source scans (D005) instead of duplicating a +// subtle scanner. +pub(crate) mod cjs_wrap; mod codegen_steps; mod collect_modules; mod compressed_libs; diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs index a0a8a3de6c..2b52b4f635 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs @@ -77,10 +77,45 @@ pub fn extract_require_aliases_with_ranges(source: &str) -> Vec<(String, String, r#"(?m)^\s*(?:var|const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*(?:;|$)"#, ) .unwrap(); + let bytes = source.as_bytes(); let mut seen = Vec::new(); let mut out = Vec::new(); for cap in re.captures_iter(source) { if let (Some(alias), Some(spec), Some(whole)) = (cap.get(1), cap.get(2), cap.get(0)) { + // Comma-continued declarator lists (pre-ES6 "comma-first" style). + // + // The trailing `(?m)$` lets a match end at end-of-LINE, so + // + // var compileSchema = require('./compile') + // , resolve = require('./compile/resolve') + // , Cache = require('./cache'); + // + // matches declarator #0 only. Blanking that range leaves the + // continuation `, resolve = require('./compile/resolve')` dangling + // at statement position, which parses as TS1109 ("Expression + // expected") — the same failure shape as the `.EventEmitter;` + // case in issue #845, just reached via a comma instead of a member + // access. Hit in the wild by ajv 6.x (`lib/ajv.js`, + // `lib/compile/index.js`) via the Vercel CLI corpus. + // + // Skip the entire declaration: with no alias entry nothing is + // blanked, the body keeps the original (valid) multi-declarator + // statement, and the IIFE-bound `require` resolves each specifier + // at runtime. The specifiers still become module-scope imports via + // `extract_require_specifiers`; only the alias-adoption + // optimization is forfeited. This is the same safe fallback the + // wrap already takes when it refuses an adoption. + // + // The single-line form `var a = require('x'), b = 42;` never + // matched in the first place (`\s*(?:;|$)` rejects the `,`), so + // this check only affects the multi-line style. + let mut p = whole.end(); + while p < bytes.len() && (bytes[p] as char).is_whitespace() { + p += 1; + } + if p < bytes.len() && bytes[p] == b',' { + continue; + } let alias = alias.as_str().to_string(); if seen.contains(&alias) { continue; diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index 5fddf3eac5..4d122128ac 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -36,7 +36,7 @@ //! follows up to a small depth (2 levels) to handle one level of env //! switching; deeper indirection is rare and gets the no-op fallback. -pub(in crate::commands::compile) mod detect; +pub(crate) mod detect; mod extract_exports; mod extract_requires; mod hoist_classes; @@ -1534,6 +1534,67 @@ module.exports = SafeBuffer;"#; assert_eq!(aliases[0].1, "net"); } + #[test] + fn require_alias_extract_skips_comma_first_declarator_list() { + // ajv 6.x `lib/ajv.js` — pre-ES6 comma-first declarator list. + // + // The trailing `(?m)$` in the alias regex lets a match end at + // end-of-LINE, so only declarator #0 (`compileSchema`) matched. + // Blanking that range left `, resolve = require('./compile/resolve')` + // dangling at statement position -> TS1109. + // + // A multi-declarator statement must yield NO aliases: nothing is + // blanked, the body keeps the valid original, and the IIFE `require` + // resolves each spec at runtime. + let src = "'use strict';\n\ + var compileSchema = require('./compile')\n\ + , resolve = require('./compile/resolve')\n\ + , Cache = require('./cache');\n\ + var standalone = require('./standalone');\n"; + let aliases = extract_require_aliases_with_ranges(src); + assert_eq!( + aliases.len(), + 1, + "comma-first list must yield no aliases; only the standalone \ + single-declarator statement should match, got: {:?}", + aliases + ); + assert_eq!(aliases[0].0, "standalone"); + assert_eq!(aliases[0].1, "./standalone"); + } + + #[test] + fn wrap_does_not_dangle_comma_continuation_after_blanking() { + // Regression test for the ajv 6.x comma-first shape: the wrap output + // must stay parseable. A top-level class declaration is included to + // force the blanking pass to run. + let src = "'use strict';\n\ + var compileSchema = require('./compile')\n\ + , resolve = require('./compile/resolve')\n\ + , Cache = require('./cache');\n\ + class Ajv { constructor() { this.c = new Cache(); } }\n\ + module.exports = Ajv;\n"; + let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/ajv.js")); + // The declaration must survive INTACT. Before the fix, declarator #0 + // was blanked to spaces while `, resolve = …` / `, Cache = …` stayed, + // leaving a comma at statement position. A leading `,` on a line is + // fine on its own — it is a legal continuation — so the meaningful + // assertions are that the head is still there and the whole thing + // still parses. + assert!( + wrapped.contains("var compileSchema = require('./compile')"), + "declarator #0 was blanked, leaving its continuations dangling:\n{}", + wrapped + ); + let parsed = perry_parser::parse_typescript(&wrapped, "ajv.js"); + assert!( + parsed.is_ok(), + "wrap output failed to parse: {:?}\nwrapped:\n{}", + parsed.err(), + wrapped + ); + } + #[test] fn wrap_does_not_dangle_member_access_after_blanking() { // Regression test for issue #845: the wrap output must remain diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index dee2bf8d41..21f7fe8b25 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -51,7 +51,7 @@ use import_helpers::{ // Re-exported at `pub(super)` because `compile.rs` (the parent module) calls // `collect_modules::known_node_submodule_key` directly. pub(super) use import_helpers::known_node_submodule_key; -use native_addon::refuse_compile_package_native_addon; +use native_addon::{refuse_compile_package_native_addon, refuse_node_addon_binary}; use parse_error::annotate_parse_error; use static_require_transform::transform_static_literal_requires; use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; @@ -311,6 +311,7 @@ fn collect_module_one( }); } + refuse_node_addon_binary(&canonical)?; let source = fs::read_to_string(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))?; progress.record(ProgressSnapshot { @@ -420,6 +421,7 @@ fn collect_module_one( stub.source } else { // It's a TypeScript (or synthetic JSON/text) file to compile natively. + refuse_node_addon_binary(&canonical)?; fs::read_to_string(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))? }; diff --git a/crates/perry/src/commands/compile/collect_modules/native_addon.rs b/crates/perry/src/commands/compile/collect_modules/native_addon.rs index d5de61317c..a145112ef0 100644 --- a/crates/perry/src/commands/compile/collect_modules/native_addon.rs +++ b/crates/perry/src/commands/compile/collect_modules/native_addon.rs @@ -127,6 +127,37 @@ fn package_json_dependency_uses_native_addon_loader( }) } +/// A `.node` file is a compiled N-API addon — a Mach-O/ELF/PE shared object, +/// not source. Both module-read paths in `collect_modules` call +/// `fs::read_to_string`, so reaching one with a `.node` file reports +/// "stream did not contain valid UTF-8", which names neither the real +/// constraint nor the package responsible. +/// +/// `refuse_compile_package_native_addon` already covers the case where the +/// addon sits in a package that resolved to a `compilePackages` root, but a +/// platform-specific sidecar package (the napi-rs layout: `@napi-rs/keyring` +/// depends on `@napi-rs/keyring-darwin-arm64`, which contains nothing but the +/// `.node` file and a package.json) can be reached without its root ever being +/// classified. Guard the read itself so the diagnostic is the same either way. +pub(super) fn refuse_node_addon_binary(canonical: &std::path::Path) -> Result<()> { + if canonical.extension().and_then(|ext| ext.to_str()) != Some("node") { + return Ok(()); + } + let package_name = nearest_package_root(canonical) + .and_then(|root| package_name_from_package_json(&root)) + .unwrap_or_else(|| canonical.display().to_string()); + anyhow::bail!( + "`{}` is a Node native addon (`{}`).\n\ + Perry cannot load Node `.node` / N-API addons inside a native Perry binary. \ + Remove `{}` from `perry.compilePackages`, choose a pure JS/TS package, \ + or replace the native boundary with a Perry native binding \ + (`perry.nativeLibrary` / perry-ffi).", + package_name, + canonical.display(), + package_name, + ); +} + pub(super) fn refuse_compile_package_native_addon( ctx: &mut CompilationContext, canonical: &std::path::Path, diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 5b20bb0ff2..1a867f22a2 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -7,13 +7,9 @@ use crate::OutputFormat; use super::super::{find_perry_workspace_root, rust_target_triple, CompilationContext}; -fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .expect("env lock poisoned") -} +// The env guard now lives at crate root so tests OUTSIDE this module — which +// read `PATH` while these tests swap it — can take the same lock. +use crate::test_env_lock::env_lock; fn set_env_var(key: &str, value: Option<&str>) { match value { diff --git a/crates/perry/src/commands/deps.rs b/crates/perry/src/commands/deps.rs index 1b863ac7a8..21cddee9a4 100644 --- a/crates/perry/src/commands/deps.rs +++ b/crates/perry/src/commands/deps.rs @@ -323,6 +323,87 @@ fn is_supported_node_builtin(name: &str) -> bool { ) } +/// Does this package ship TypeScript declarations? +/// +/// The old test probed three hardcoded paths (`index.d.ts`, +/// `dist/index.d.ts`, `types/`) and ignored the *canonical* mechanism — the +/// `types` / `typings` field in package.json, and the `types` condition inside +/// an `exports` map. Packages that declare types anywhere else were reported +/// as untyped: `date-fns` (`./typings.d.ts`), `tldts` +/// (`dist/types/index.d.ts`) and `@inquirer/*` (`./dist/cjs/types/index.d.ts`) +/// all tripped it in the Vercel CLI corpus. +/// +/// Resolution order: +/// 1. `types` / `typings` in package.json (resolved relative to the package, +/// accepting the extensionless form npm also allows). +/// 2. Any `"types"` key appearing in the `exports` map (nested conditions +/// included) — checked by key presence, since exports targets can be +/// arbitrarily nested per subpath/condition. +/// 3. The legacy hardcoded layouts. +/// 4. A bounded scan for any `.d.ts` in the package root or `dist/`, which +/// covers hand-rolled layouts without walking a huge tree. +fn package_declares_types(package_path: &Path) -> bool { + let manifest = package_path.join("package.json"); + if let Ok(content) = fs::read_to_string(&manifest) { + if let Ok(json) = serde_json::from_str::(&content) { + for key in ["types", "typings"] { + if let Some(rel) = json.get(key).and_then(|v| v.as_str()) { + let rel = rel.trim_start_matches("./"); + let direct = package_path.join(rel); + if direct.exists() { + return true; + } + // npm allows the extensionless form (`"types": "./index"`). + if direct.extension().is_none() + && package_path.join(format!("{rel}.d.ts")).exists() + { + return true; + } + } + } + if let Some(exports) = json.get("exports") { + if exports_mentions_types(exports) { + return true; + } + } + } + } + + if package_path.join("index.d.ts").exists() + || package_path.join("dist").join("index.d.ts").exists() + || package_path.join("types").exists() + { + return true; + } + + for dir in [package_path.to_path_buf(), package_path.join("dist")] { + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + if entry + .file_name() + .to_str() + .is_some_and(|n| n.ends_with(".d.ts")) + { + return true; + } + } + } + } + + false +} + +/// Does an `exports` map declare a `types` condition anywhere within it? +fn exports_mentions_types(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(map) => map + .iter() + .any(|(k, v)| k == "types" || exports_mentions_types(v)), + serde_json::Value::Array(items) => items.iter().any(exports_mentions_types), + _ => false, + } +} + /// Check a package for compatibility issues pub fn check_package_compatibility( package_name: &str, @@ -342,9 +423,7 @@ pub fn check_package_compatibility( }; // Check if types are available - let has_types = package_path.join("index.d.ts").exists() - || package_path.join("dist").join("index.d.ts").exists() - || package_path.join("types").exists(); + let has_types = package_declares_types(package_path); if !has_types { // Check for @types package @@ -431,9 +510,113 @@ fn extract_version(content: &str) -> Option { None } +/// Line numbers (1-based) holding a genuinely *dynamic* `import(...)` — one +/// whose argument is not a static string literal. +/// +/// Two things the old per-line `!line.contains("import('")` test got wrong: +/// +/// 1. **Multi-line call sites.** Prettier wraps a long specifier onto its own +/// line, so `await import(\n './x'\n)` has no `import('` on the `import(` +/// line and was reported as a variable path. The identical call written on +/// one line was not — the check was formatting-sensitive rather than +/// argument-sensitive. (Both shapes appear in the same file in the Vercel +/// CLI: `commands/routes/shared.ts:293` vs `:296`.) +/// +/// 2. **`import(` inside string literals.** A loader script built as a +/// template literal and written to disk for a *child Node process* is not +/// code in this program, but was scanned as if it were +/// (`util/compile-vercel-config.ts:336`). +/// +/// Both are fixed by scanning a comment/string-masked copy of the whole source +/// and resolving the argument across newlines. Masking also removes the need +/// for the old `starts_with("//")` guard. +/// +/// A template-literal argument stays "dynamic" — same as before — because it +/// may interpolate. +fn dynamic_import_lines(source: &str) -> std::collections::HashSet { + use crate::commands::compile::cjs_wrap::detect::strip_comments_and_strings; + + let mut out = std::collections::HashSet::new(); + + // The masker returns a same-length copy (code bytes verbatim, comment and + // string bodies blanked to spaces). If a partially-masked multi-byte char + // made the lossy UTF-8 conversion change the length, byte offsets no + // longer line up — fall back to the raw source, which is what the old + // check scanned anyway. + let masked = strip_comments_and_strings(source); + let scan: &str = if masked.len() == source.len() { + &masked + } else { + source + }; + let bytes = scan.as_bytes(); + let src_bytes = source.as_bytes(); + + // Newlines inside a masked string literal are blanked to spaces, so + // restore them positionally to keep line numbering aligned with `source`. + let mut scan_owned = bytes.to_vec(); + if scan_owned.len() == src_bytes.len() { + for (i, b) in src_bytes.iter().enumerate() { + if *b == b'\n' { + scan_owned[i] = b'\n'; + } + } + } + let bytes = &scan_owned[..]; + + let is_ident = |c: u8| c == b'_' || c == b'$' || c.is_ascii_alphanumeric(); + + let mut line = 1u32; + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'\n' { + line += 1; + i += 1; + continue; + } + if bytes[i] != b'i' || !scan_owned[i..].starts_with(b"import") { + i += 1; + continue; + } + // Whole-word `import` only. + if i > 0 && is_ident(bytes[i - 1]) { + i += 1; + continue; + } + let mut p = i + "import".len(); + // `import.meta` and static `import x from '…'` both fail the `(` test + // below, so they need no special case. + while p < bytes.len() && (bytes[p] as char).is_whitespace() { + p += 1; + } + if p >= bytes.len() || bytes[p] != b'(' { + i += 1; + continue; + } + // Resolve the argument, crossing newlines. + // + // Read it from the ORIGINAL source: the masker blanks string + // delimiters as well as their bodies, so `import('./x')` is + // `import( )` in the masked copy and every call site would look + // variable. Offsets are aligned (same length), so `p` indexes both. + p += 1; + while p < src_bytes.len() && (src_bytes[p] as char).is_whitespace() { + p += 1; + } + let static_literal = p < src_bytes.len() && (src_bytes[p] == b'\'' || src_bytes[p] == b'"'); + if !static_literal { + out.insert(line); + } + i += "import".len(); + } + + out +} + /// Scan source code for compatibility issues using pattern matching fn scan_source_for_issues(path: &Path, source: &str) -> Vec { let mut issues = Vec::new(); + let dynamic_imports = dynamic_import_lines(source); for (line_num, line) in source.lines().enumerate() { let line_num = (line_num + 1) as u32; @@ -459,22 +642,16 @@ fn scan_source_for_issues(path: &Path, source: &str) -> Vec }); } - // Check for dynamic import() - // Match import( but not import.meta or static imports - if line.contains("import(") - && !line.contains("import.meta") - && !line.trim().starts_with("//") - { - // Try to determine if it's dynamic (variable argument) - let is_dynamic = !line.contains("import('") && !line.contains("import(\""); - if is_dynamic { - issues.push(CompatibilityIssue { - file: path.to_path_buf(), - line: Some(line_num), - kind: IssueKind::DynamicImport, - message: "Dynamic import() with variable path cannot be compiled".to_string(), - }); - } + // Dynamic import() — resolved whole-source in `dynamic_import_lines` + // so multi-line call sites and `import(` inside string literals are + // handled correctly. + if dynamic_imports.contains(&line_num) { + issues.push(CompatibilityIssue { + file: path.to_path_buf(), + line: Some(line_num), + kind: IssueKind::DynamicImport, + message: "Dynamic import() with variable path cannot be compiled".to_string(), + }); } // Check for explicit 'any' type (in .ts files) @@ -633,6 +810,157 @@ pub fn compatibility_to_diagnostics(packages: &[PackageCompatibility]) -> Diagno mod tests { use super::*; + /// D005 must key off the *argument*, not the line's formatting. Both call + /// shapes below appear in the same file in the Vercel CLI + /// (`commands/routes/shared.ts:293` and `:296`); only the prettier-wrapped + /// one was reported. + #[test] + fn dynamic_import_ignores_multiline_static_specifier() { + let src = "const { default: a } = await import(\n './util/a'\n);\n\ + const { default: b } = await import('./util/b');\n\ + const { c } = await import(\n \"./util/c\"\n);\n"; + assert!( + dynamic_import_lines(src).is_empty(), + "all three specifiers are static string literals; got: {:?}", + dynamic_import_lines(src) + ); + } + + /// A genuinely variable specifier must still be flagged, on the right line. + #[test] + fn dynamic_import_still_flags_variable_specifier() { + let src = "const p = compute();\n\ + const m = await import(p);\n\ + const n = await import('./static');\n"; + let lines = dynamic_import_lines(src); + assert_eq!( + lines.len(), + 1, + "exactly one dynamic site expected, got: {:?}", + lines + ); + assert!(lines.contains(&2), "expected line 2, got: {:?}", lines); + } + + /// `import(` inside a string literal is not code in this program. The + /// Vercel CLI builds a loader script as a template literal and writes it + /// to disk for a child Node process + /// (`util/compile-vercel-config.ts:336`). + #[test] + fn dynamic_import_ignores_import_inside_string_literal() { + let src = "const loaderScript = `\n\ + \x20 import { pathToFileURL } from 'url';\n\ + \x20 const mod = await import(pathToFileURL(process.argv[2]).href);\n\ + `;\n\ + await writeFile(loaderPath, loaderScript, 'utf-8');\n"; + assert!( + dynamic_import_lines(src).is_empty(), + "import() inside a template literal must not be scanned; got: {:?}", + dynamic_import_lines(src) + ); + } + + /// T002 must honor the canonical `types`/`typings` field, not just the + /// three legacy hardcoded layouts. All three shapes below were reported as + /// untyped in the Vercel CLI corpus. + #[test] + fn package_types_field_is_honored() { + let cases: &[(&str, &str)] = &[ + // date-fns + ( + r#"{"name":"date-fns","types":"./typings.d.ts"}"#, + "typings.d.ts", + ), + // tldts + ( + r#"{"name":"tldts","types":"dist/types/index.d.ts"}"#, + "dist/types/index.d.ts", + ), + // @inquirer/confirm + ( + r#"{"name":"confirm","types":"./dist/cjs/types/index.d.ts"}"#, + "dist/cjs/types/index.d.ts", + ), + // legacy `typings` spelling + ( + r#"{"name":"old","typings":"lib/main.d.ts"}"#, + "lib/main.d.ts", + ), + // extensionless form npm also allows + (r#"{"name":"ext","types":"./index"}"#, "index.d.ts"), + ]; + for (manifest, decl_rel) in cases { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("package.json"), manifest).unwrap(); + let decl = root.join(decl_rel); + std::fs::create_dir_all(decl.parent().unwrap()).unwrap(); + std::fs::write(&decl, "export {};\n").unwrap(); + assert!( + package_declares_types(root), + "manifest {manifest} with {decl_rel} must count as typed" + ); + } + } + + /// A `types` condition inside an `exports` map also counts. + #[test] + fn package_exports_types_condition_is_honored() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("package.json"), + r#"{"name":"m","exports":{".":{"import":{"types":"./d.ts","default":"./m.js"}}}}"#, + ) + .unwrap(); + assert!(package_declares_types(root)); + } + + /// A genuinely untyped package must still be flagged. + #[test] + fn package_without_declarations_is_still_flagged() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("package.json"), + r#"{"name":"plain","main":"index.js"}"#, + ) + .unwrap(); + std::fs::write(root.join("index.js"), "module.exports = {};\n").unwrap(); + assert!( + !package_declares_types(root), + "package ships no declarations; T002 must still fire" + ); + } + + /// A `types` field pointing at a file that does not exist must not count. + #[test] + fn package_types_field_pointing_nowhere_is_not_honored() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("package.json"), + r#"{"name":"broken","types":"./missing.d.ts"}"#, + ) + .unwrap(); + std::fs::write(root.join("index.js"), "module.exports = {};\n").unwrap(); + assert!(!package_declares_types(root)); + } + + /// `import.meta` and static ESM imports must never be mistaken for + /// dynamic `import()` calls. + #[test] + fn dynamic_import_ignores_import_meta_and_static_imports() { + let src = "import chalk from 'chalk';\n\ + import { join } from 'node:path';\n\ + const dir = import.meta.url;\n"; + assert!( + dynamic_import_lines(src).is_empty(), + "got: {:?}", + dynamic_import_lines(src) + ); + } + /// #3744: `perry check` must not report a clean build for modern Node /// builtins that `perry compile` rejects. The builtin table feeds the /// U006 diagnostic only when a name is recognized as a Node builtin AND diff --git a/crates/perry/src/commands/install/lifecycle.rs b/crates/perry/src/commands/install/lifecycle.rs index 7757b9ffc1..2e56122a25 100644 --- a/crates/perry/src/commands/install/lifecycle.rs +++ b/crates/perry/src/commands/install/lifecycle.rs @@ -296,6 +296,12 @@ mod tests { // Real shell-out: write a tiny package, allowlist it via // run_scripts_all, and assert the postinstall ran (it touches // a sentinel file). + // + // `augment_path` reads the process-global `PATH` to resolve `sh`, and + // `optimized_libs::tests` swaps `PATH` for a fake, `sh`-less directory + // while it runs. Without sharing that lock this spawns with the fake + // PATH and fails as `No such file or directory`. + let _env = crate::test_env_lock::env_lock(); let td = TempDir::new().unwrap(); let pkg_dir = td.path().join("node_modules/sentinel-pkg"); fs::create_dir_all(&pkg_dir).unwrap(); diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index b3a8841bca..7e773955e2 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -5,6 +5,8 @@ mod commands; mod compat_reports; mod telemetry; +#[cfg(test)] +mod test_env_lock; mod update_checker; use anyhow::Result; diff --git a/crates/perry/src/test_env_lock.rs b/crates/perry/src/test_env_lock.rs new file mode 100644 index 0000000000..c72d8bfc3c --- /dev/null +++ b/crates/perry/src/test_env_lock.rs @@ -0,0 +1,30 @@ +//! Process-wide lock for tests that read or mutate process environment. +//! +//! `std::env::set_var`/`remove_var` mutate state shared by every test thread, +//! so a test that swaps `PATH` races any concurrently-running test that reads +//! it. `optimized_libs::tests` already serialized its own PATH swaps behind a +//! module-private mutex, but `install::lifecycle`'s `run_lifecycle_executes_ +//! script` reads `PATH` (via `augment_path`) to resolve `sh` — without sharing +//! that lock it could observe the fake, `sh`-less PATH and fail to spawn with +//! `No such file or directory`. +//! +//! Both sides now take THIS lock, so the guard is genuinely process-wide. +//! Any future test that touches env vars should take it too. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +/// Acquire the process-wide environment lock. +/// +/// Fails closed on poisoning, matching the guard this replaced. Callers restore +/// the environment *after* the work they wrap, so a panic mid-test unwinds +/// without restoring — leaving `PATH` pointing at a deleted temp dir. Recovering +/// the lock there would hand that broken environment to every subsequent test +/// and turn one failure into a cascade that no longer names its cause. Poison +/// means "a test that owns the environment died"; stopping is the useful answer. +pub(crate) fn env_lock() -> MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock poisoned — a test panicked while owning the process environment") +} diff --git a/test-files/test_gap_enum_in_function_body.ts b/test-files/test_gap_enum_in_function_body.ts new file mode 100644 index 0000000000..103336091b --- /dev/null +++ b/test-files/test_gap_enum_in_function_body.ts @@ -0,0 +1,86 @@ +// An enum declared inside a FUNCTION BODY is valid TypeScript. Perry only +// registered enums at module scope, so `lower_body_stmt` bailed with +// "enum declared inside a function body is not supported". Registration in the +// lowering context is what makes the name resolve; the declaration must also +// reach `Module::enums`, which is what codegen consults to resolve +// `Expr::EnumMember`. +// +// Found via the Vercel CLI corpus +// (packages/build-utils/src/ruby-diagnostics.ts declares `Section` and +// `SubSection` inside `parseGemfileLock`). + +// String enum local to a function, driving assignment + comparison — the +// ruby-diagnostics shape. +function parse(content: string): string[] { + enum Section { + GEM = "gem", + GIT = "git", + DEPENDENCIES = "dependencies", + } + + let section: Section | null = null; + const out: string[] = []; + + for (const line of content.split("\n")) { + if (line === "GEM") section = Section.GEM; + else if (line === "GIT") section = Section.GIT; + else if (line === "DEPENDENCIES") section = Section.DEPENDENCIES; + if (section === Section.GEM) out.push("gem:" + line); + } + return out; +} +console.log("parse:", parse("GEM\nfoo\nGIT\nbar").join(",")); // gem:GEM,gem:foo + +// Numeric (auto-incremented) enum in a function body, including the reverse +// mapping a numeric enum carries. +function levels(): string { + enum Level { + LOW, + MEDIUM, + HIGH, + } + return `${Level.LOW},${Level.MEDIUM},${Level.HIGH},${Level[2]}`; +} +console.log("levels:", levels()); // 0,1,2,HIGH + +// Explicit numeric values, read through a switch. +function classify(n: number): string { + enum Code { + OK = 200, + NOT_FOUND = 404, + } + switch (n) { + case Code.OK: + return "ok"; + case Code.NOT_FOUND: + return "missing"; + default: + return "other"; + } +} +console.log("classify:", classify(404), classify(200), classify(1)); // missing ok other + +// A body-local enum inside a nested function. +function outer(): string { + function inner(): string { + enum Inner { + A = "a", + B = "b", + } + return Inner.A + Inner.B; + } + return inner(); +} +console.log("nested:", outer()); // ab + +// A body-local enum must not disturb a module-scope enum of a different name. +enum Global { + ONE = "one", +} +function usesGlobal(): string { + enum Local { + TWO = "two", + } + return Global.ONE + "/" + Local.TWO; +} +console.log("mixed:", usesGlobal()); // one/two