Skip to content

fix(compile/hir): five Vercel CLI corpus blockers — CJS comma-first requires, body-local enums, D005/T002 false positives, .node diagnostic - #6885

Merged
proggeramlug merged 1 commit into
mainfrom
fix/vercel-corpus-compat
Jul 27, 2026
Merged

fix(compile/hir): five Vercel CLI corpus blockers — CJS comma-first requires, body-local enums, D005/T002 false positives, .node diagnostic#6885
proggeramlug merged 1 commit into
mainfrom
fix/vercel-corpus-compat

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Found by compiling the Vercel CLI end to end (real-apps/vercel, 1137 TS files / 168k LOC, 23 workspace packages). Each fix is the thing that was actually blocking the next step, in the order it was hit.

Fixes

1. CJS→ESM wrap: comma-first require declarator lists (the blocker)

ajv 6.x uses the pre-ES6 style:

var compileSchema = require('./compile')
  , resolve = require('./compile/resolve')
  , Cache = require('./cache');

The alias regex ends at (?m)$, so 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.

Multi-declarator statements are 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.

I deliberately did not "blank the whole VariableDeclaration". Adoption is decided per-alias, so blanking only the adopted declarators of a list reintroduces the identical dangling-comma bug. The single-line form var a = require('x'), b = 42; never matched to begin with (\s*(?:;|$) rejects the ,), so this only affects the multi-line style.

2. enum inside a function body now compiles

Valid TypeScript that bailed with "declare it at module scope". Member access is materialized from ctx.lookup_enum, but codegen resolves Expr::EnumMember against Module::enums, which body-local declarations had no route to — registering alone yields enum member Section.GEM not found in enums table. Added LoweringContext::pending_body_enums, drained in lower_module_full 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.

3. D005 no longer fires on static specifiers

The check was a per-line !line.contains("import('") test, so it keyed off formatting rather than the argument:

// routes/shared.ts:293  → FLAGGED
const { default: a } = await import(
  '../../util/routes/update-route-version'
);
// routes/shared.ts:296  → NOT flagged (same file)
const { default: b } = await import('../../util/output/stamp');

It also scanned import( inside string literals — flagging a loader script 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. The argument is read from the original source, because the masker blanks string delimiters too.

4. T002 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. Now checks types/typings (incl. the extensionless form), a types condition anywhere in exports, the legacy layouts, then a bounded .d.ts scan.

5. A .node addon reached as a module 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. 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 is now guarded directly.

Corpus effect

perry check --check-deps    4 errors / 20 T002 warnings  →  0 errors / 5 warnings

All 5 remaining T002s are genuine — four are exactly the packages the Vercel repo hand-writes ambient declarations for in packages/cli/types/.

perry compile now reaches the native-addon boundary with ajv unpatched and the enum unhoisted, and reports it clearly instead of a UTF-8 decode failure.

Tests

  • 10 new unit tests (cjs_wrap + deps), including the multi-line/single-line D005 pair and the four types layouts.
  • test-files/test_gap_enum_in_function_body.ts — 5 cases incl. numeric reverse mapping and nesting. Note this auto-skips in run_parity_tests.sh (Node's strip-only mode cannot execute enums at all, same as the existing test_gap_4510_enum_forward_ref.ts); it is covered by compile-smoke.
  • perry bin suite 751/751 green, stable across 3 consecutive runs.

One internal fix included

install::lifecycle's run_lifecycle_executes_script reads process-global PATH (via augment_path) to resolve sh, while optimized_libs::tests swaps PATH for a fake sh-less directory behind a module-private mutex. The lock was never shared, so the two could race. Latent before this branch — the added tests shifted scheduling enough to surface it as failed to spawn shell … No such file or directory. The guard moved to crate::test_env_lock and both sides take it.

Notes for review

  • mod cjs_wrap and mod detect were widened to pub(crate) so deps.rs can reuse detect's comment/string masker rather than duplicate a subtle scanner. Happy to swap for a local copy if you'd rather not expose those.
  • cargo fmt --all --check reports one hunk in collect_modules.rs (a comment re-indent around line 766) that is pre-existing on main under rustfmt 1.9.0 — verified by running fmt with the pristine file swapped in. Left untouched so this PR stays scoped.
  • perry-hir's logical_property_assignment_short_circuits_the_store_4586 (o.x ??= 2) fails, and also fails on main with this branch stashed. Unrelated and pre-existing.
  • The changelog fragment is named 6875-…; rename to the real PR number if it differs.

Summary by CodeRabbit

  • New Features
    • Added support for TypeScript enums declared inside function and nested-function bodies, with correct name/member resolution.
  • Bug Fixes
    • Fixed CommonJS wrapping for comma-first/multiline require() declarator patterns.
    • Improved dynamic import(...) detection to reduce false positives and handle multi-line specifiers.
    • Added clearer handling for native Node .node add-ons during compilation.
    • Enhanced TypeScript declaration compatibility checks using modern types/typings and exports resolution.
  • Documentation
    • Updated the changelog and bumped the project version to 0.5.1265.

@proggeramlug
proggeramlug force-pushed the fix/vercel-corpus-compat branch from cf2db49 to a791d17 Compare July 27, 2026 03:44
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d75d60fe-24a1-4041-86c0-153728caf2b8

📥 Commits

Reviewing files that changed from the base of the PR and between a791d17 and 57d935d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/6885-vercel-cli-corpus-compat.md
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/native_addon.rs
  • crates/perry/src/commands/compile/optimized_libs/tests.rs
  • crates/perry/src/commands/deps.rs
  • crates/perry/src/commands/install/lifecycle.rs
  • crates/perry/src/main.rs
  • crates/perry/src/test_env_lock.rs
  • test-files/test_gap_enum_in_function_body.ts
🚧 Files skipped from review as they are similar to previous changes (16)
  • Cargo.toml
  • CLAUDE.md
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • test-files/test_gap_enum_in_function_body.ts
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry/src/commands/compile/collect_modules/native_addon.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry/src/main.rs
  • crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs
  • crates/perry/src/commands/install/lifecycle.rs
  • crates/perry/src/commands/deps.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • changelog.d/6885-vercel-cli-corpus-compat.md
  • crates/perry/src/commands/compile/optimized_libs/tests.rs

📝 Walkthrough

Walkthrough

The changes add function-body enum lowering, improve CJS wrapper handling, expand dependency compatibility checks, reject native addon binaries earlier, serialize environment-sensitive tests, and update release metadata.

Changes

Compatibility and release updates

Layer / File(s) Summary
Function-body enum lowering
crates/perry-hir/src/lower/..., test-files/test_gap_enum_in_function_body.ts, changelog.d/...
Function-body enums are validated, queued in lowering context, attached during module finalization, and covered by string, numeric, nested-scope, and name-isolation tests.
CJS wrapper declarator handling
crates/perry/src/commands/compile/..., changelog.d/...
Comma-continued require declarators are skipped during alias extraction, with visibility updates and parsing regression tests.
Dependency compatibility scanning
crates/perry/src/commands/deps.rs, changelog.d/...
Type declarations are resolved through package metadata, exports conditions, legacy layouts, and bounded scans; dynamic imports are detected across lines while masking non-code content.
Native addon rejection
crates/perry/src/commands/compile/collect_modules/..., changelog.d/...
Resolved .node files are rejected consistently in both module collection paths with package-aware diagnostics.
Process-wide test environment locking
crates/perry/src/test_env_lock.rs, crates/perry/src/main.rs, crates/perry/src/commands/{optimized_libs/tests.rs,install/lifecycle.rs}, changelog.d/...
A shared test-only environment mutex coordinates PATH-sensitive tests.
Version and changelog metadata
Cargo.toml, CLAUDE.md, changelog.d/...
The workspace and documented project versions advance to 0.5.1265, and compatibility validation results are recorded.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FunctionBody
  participant LoweringContext
  participant lower_module_full
  participant Module
  FunctionBody->>LoweringContext: queue lowered enum
  lower_module_full->>LoweringContext: drain pending_body_enums
  lower_module_full->>Module: register non-duplicate enums
  Module-->>FunctionBody: resolve enum members during code generation
Loading

Possibly related issues

Suggested labels: bug

Suggested reviewers: andrewtdiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it doesn't follow the required template and omits the Summary, Changes, Related issue, Test plan, and Checklist sections. Reformat it to the repo template with those headings and include a related issue plus a concrete test plan and checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fixes, including CJS wrapping, body-local enums, D005/T002, and native-addon diagnostics.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/vercel-corpus-compat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs (1)

75-118: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Comment between require(...) and the continuation comma defeats the new lookahead.

The whitespace-skip loop at Lines 112-115 only skips literal whitespace bytes. A trailing line comment (or block comment) between the closing paren and the continuation comma — e.g. require('./compile') // note\n , resolve = require(...) — stops the scan at the comment text, so the comma is never found, the declarator is (incorrectly) treated as a standalone alias, gets blanked, and the surviving continuation dangles at statement position: the exact TS1109 failure shape this PR is fixing, just reached via a comment instead of a member access.

cjs_wrap::detect::strip_comments_and_strings (already used analogously in deps.rs's new dynamic_import_lines) can mask this out cheaply.

🐛 Proposed fix: mask comments before the comma lookahead
     let bytes = source.as_bytes();
+    // Mask comments/strings so a trailing comment between the require call
+    // and a comma-first continuation doesn't hide the comma from the
+    // lookahead below (same failure shape as the unmasked comma case).
+    let masked = super::detect::strip_comments_and_strings(source);
+    let scan_bytes: &[u8] = if masked.len() == bytes.len() {
+        masked.as_bytes()
+    } else {
+        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)) {
             ...
             let mut p = whole.end();
-            while p < bytes.len() && (bytes[p] as char).is_whitespace() {
+            while p < scan_bytes.len() && (scan_bytes[p] as char).is_whitespace() {
                 p += 1;
             }
-            if p < bytes.len() && bytes[p] == b',' {
+            if p < scan_bytes.len() && scan_bytes[p] == b',' {
                 continue;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs` around lines
75 - 118, Update the comma-continuation lookahead in
extract_require_aliases_with_ranges to ignore comments between the matched
require expression and a following comma, using
cjs_wrap::detect::strip_comments_and_strings or the existing analogous masking
approach. Ensure whitespace and both line/block comments are skipped before
checking for the comma, so commented multi-line declarators are treated as whole
declarations and not registered as aliases.
🧹 Nitpick comments (1)
crates/perry/src/commands/compile/collect_modules/native_addon.rs (1)

142-159: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a regression test for the direct .node guard.

The supplied tests cover the compilePackages rejection path, but not this helper’s intended sidecar-package path. Add a fixture with a regular resolved package containing package.json and addon.node, and assert that the error includes the package name and native-addon guidance; otherwise this path can regress to the old UTF-8 failure unnoticed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/src/commands/compile/collect_modules/native_addon.rs` around
lines 142 - 159, Add a regression fixture for refuse_node_addon_binary using a
resolved package directory containing package.json and addon.node, then invoke
the helper with the addon path and assert the failure includes the package name
and native-addon guidance. Keep the existing compilePackages rejection coverage
unchanged and ensure the test exercises package-name resolution rather than a
direct standalone path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-hir/src/lower_decl/body_stmt.rs`:
- Around line 1984-2012: Preserve lexical identity for body-local enums instead
of treating every same-named declaration as global: update enum resolution and
registration around the body-local lowering logic in
crates/perry-hir/src/lower_decl/body_stmt.rs (anchor lines 1984-2012), retain
each identity without name-only deduplication in
crates/perry-hir/src/lower/lower_module_fn.rs (sibling lines 1188-1198), add
separate same-named local enums with different values and a local enum shadowing
a module enum in test-files/test_gap_enum_in_function_body.ts (sibling lines
63-74), and remove the obsolete same-name limitation from
changelog.d/6885-vercel-cli-corpus-compat.md (sibling lines 21-28).

In `@crates/perry/src/test_env_lock.rs`:
- Around line 18-26: The env_lock function currently recovers poisoned mutexes
even though callers may leave PATH modified during unwinding. Make environment
restoration in the optimized library tests panic-safe so cleanup runs during
unwinding, or change env_lock to fail closed on poisoning after verifying every
caller restores the environment. Ensure a panic cannot expose subsequent tests
to a modified or deleted PATH.

---

Outside diff comments:
In `@crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs`:
- Around line 75-118: Update the comma-continuation lookahead in
extract_require_aliases_with_ranges to ignore comments between the matched
require expression and a following comma, using
cjs_wrap::detect::strip_comments_and_strings or the existing analogous masking
approach. Ensure whitespace and both line/block comments are skipped before
checking for the comma, so commented multi-line declarators are treated as whole
declarations and not registered as aliases.

---

Nitpick comments:
In `@crates/perry/src/commands/compile/collect_modules/native_addon.rs`:
- Around line 142-159: Add a regression fixture for refuse_node_addon_binary
using a resolved package directory containing package.json and addon.node, then
invoke the helper with the addon path and assert the failure includes the
package name and native-addon guidance. Keep the existing compilePackages
rejection coverage unchanged and ensure the test exercises package-name
resolution rather than a direct standalone path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8aac83e7-1ae4-41bb-963f-6e13a272207d

📥 Commits

Reviewing files that changed from the base of the PR and between 7ae2fb4 and a791d17.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/6885-vercel-cli-corpus-compat.md
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/native_addon.rs
  • crates/perry/src/commands/compile/optimized_libs/tests.rs
  • crates/perry/src/commands/deps.rs
  • crates/perry/src/commands/install/lifecycle.rs
  • crates/perry/src/main.rs
  • crates/perry/src/test_env_lock.rs
  • test-files/test_gap_enum_in_function_body.ts

Comment thread crates/perry-hir/src/lower_decl/body_stmt.rs
Comment thread crates/perry/src/test_env_lock.rs Outdated
…equires, body-local enums, D005/T002 false positives, .node diagnostic

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.
@proggeramlug
proggeramlug force-pushed the fix/vercel-corpus-compat branch from a791d17 to 57d935d Compare July 27, 2026 03:57
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Thanks — went through both.

test_env_lock.rs poisoning — fixed. Fair hit. The guard this replaced used .expect("env lock poisoned") and I changed it to recover without saying why that was safe. It isn't: optimized_libs::tests restores PATH after the build call returns, so a panic mid-test unwinds without restoring and leaves PATH pointing at a deleted temp dir. Recovering the lock hands that broken environment to every subsequent test and turns one failure into a cascade that no longer names its cause. Restored to fail-closed with the reasoning in the doc comment. 751/751 still green.

Body-local enum lexical identity — real, but deferring. Agreed this is incomplete: two functions declaring enum Section with different members, or a local enum shadowing a module one, are both valid TS and currently hit the diagnostic.

Deferring rather than fixing here, for two reasons:

  1. The current behavior is safe. lower_enum_decl reuses an existing registration's members when the name is taken, so the failure mode without the check is a silent miscompile — the second enum reading the first's values. The check turns that into a compile error. It is strictly better than main, which rejected every body-local enum outright.
  2. The fix is an IR change, not a local one. Expr::EnumMember { enum_name: String, member_name: String } identifies enums by name, and codegen resolves against Module::enums by that name. Scope-aware identity means either threading EnumId through the IR node and codegen, or mangling registered names plus a scope-alias stack consulted by lookup_enum (used in expr_member, expr_object, pre_scan, and lower_enum_decl itself). Both are worth doing deliberately, not folded into a PR whose point is unblocking a corpus.

Happy to open a follow-up issue with that analysis if useful. The changelog fragment documents the limitation explicitly so it isn't silently inherited.

@proggeramlug
proggeramlug merged commit fc640ff into main Jul 27, 2026
3 checks passed
@proggeramlug
proggeramlug deleted the fix/vercel-corpus-compat branch July 27, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant