Skip to content

fix(debug-symbols): destructure-of-undefined location coverage + CJS-wrap coordinate mapping#5321

Merged
proggeramlug merged 3 commits into
mainfrom
fix/cjs-wrap-diag-coords
Jun 17, 2026
Merged

fix(debug-symbols): destructure-of-undefined location coverage + CJS-wrap coordinate mapping#5321
proggeramlug merged 3 commits into
mainfrom
fix/cjs-wrap-diag-coords

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Improves --debug-symbols source-location diagnostics for two runtime-throw classes that were previously misreported in native-npm package compiles. Both fixes are gated behind --debug-symbols; the default build is unchanged (byte-identical, zero added emission/cloning).

Gap 1 — coverage: destructure-of-undefined (pino)

js_set_call_location was emitted only at dynamic call/new dispatch + a couple of property/console paths, never for object destructuring. A throw like const {a} = undefined (Cannot convert undefined or null to object) left CURRENT_CALL_LOCATION stale, so the rendered at <file>:<line> pointed at the last tracked call — in a different file.

Fix: the HIR object-pattern lowering (pattern_binding.rs) now carries the pattern's source byte offset as a second literal arg to the requireObjectCoercible runtime call. Codegen (lower_call/native/mod.rs) emits js_set_call_location immediately before js_require_object_coercible — but only under --debug-symbols (default codegen reads args.first() only, so the extra arg is inert).

  • pino before: at safe-stable-stringify/index.js:272 (stale, wrong file)
  • pino after: at node_modules/pino/pino.js:23 (the const { …syms… } = symbols destructure — matches Node's intuition: Node points at pino.js:23 too)

Not done (deferred): matching Node's richer message Cannot destructure property 'a' of 'x' as it is undefined. Doing so cheaply isn't possible — js_require_object_coercible is shared across empty patterns and assignment-destructuring and has no property/source-name context; it would need a new per-destructure runtime entry point threading the property name + source identifier text. Left as-is (generic ToObject message); the location is now correct.

Gap 2 — CJS-wrap coordinate skew (ajv)

A CommonJS module is parsed as WRAPPED text (wrap_commonjs_for_target injects an IIFE + module/exports/require preamble), so HIR byte_offsets are in wrapped coordinates — but module_source was read fresh from disk (the ORIGINAL file), shifting every resolved line by the preamble length (ajv also rendered <anonymous>).

Fix: under --debug-symbols, collect_modules records per CJS-wrapped module (ctx.cjs_wrap_debug_sources): the wrapped source + the wrapper-prefix line count (newlines before the original body, located via the new wrap_commonjs_with_body_offset; blanking/hoisting are newline-preserving so body line offsets are stable, and the create_require prepend is accounted for by a line delta). Codegen resolves offsets against the WRAPPED source then subtracts the prefix line count (StringPool::call_location_for + debug_source_line_offset on CompileOptions), recovering original-source lines. Offsets inside the preamble resolve to no location.

  • ajv before: undefined is not a constructor at <anonymous>
  • ajv after: at node_modules/ajv/dist/compile/codegen/scope.js:17 (the real new code_1.Name(...) site)

Minimal CJS repro: a node_modules/x module throwing on line 5 → perry reports index.js:5 (matches Node); the wrapped line was 106 (skew ~101 lines).

Default path unchanged

  • Codegen only emits the location call / subtracts the offset under --debug-symbols; the map is only populated then.
  • Regression test no_call_location_without_debug_symbols asserts no js_set_call_location CALL is emitted by default.
  • Corpus (PERRY_BIN=… python3 run.py, no --debug-symbols): 53/59 — no regressions (the 6 failures are pre-existing unrelated gaps: ajv/pino/bluebird/execa/semver/winston).

Tests added

  • strings.rs unit tests: prefix-line subtraction + preamble→None.
  • cjs_wrap unit test: wrap_commonjs_with_body_offset maps a wrapped body line back to its original-source line.
  • destructure_call_location.rs codegen IR-assertion test: location call precedes the coercibility check under --debug-symbols, none by default.

Version / changelog

Per contributor convention, this PR does NOT bump Cargo.toml version, CLAUDE.md Current Version, CHANGELOG.md, or Cargo.lock. Maintainer to fold in metadata at merge.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed debug symbol locations to correctly point to original source lines in CommonJS-wrapped modules, ensuring "Cannot convert undefined or null to object" errors in destructuring operations report the accurate file and line number.

Ralph Küpper added 2 commits June 17, 2026 12:42
…-wrap coordinate mapping

Gap 1 (coverage): emit js_set_call_location at the object-destructuring
RequireObjectCoercible site so a destructure-of-undefined throw renders
at the destructure line instead of the stale last-tracked call. The
obj-pattern byte offset rides as a 2nd literal arg to the
requireObjectCoercible runtime call; codegen acts on it only under
--debug-symbols (default build byte-identical).

Gap 2 (CJS-wrap skew): cjs-wrapped modules parse WRAPPED text, so HIR
byte_offsets are in wrapped coords but resolved against the original
file. Record the wrapper prefix line count + wrapped source per module
(only under --debug-symbols), pass wrapped source as module_source and
debug_source_line_offset to codegen, and subtract the prefix in
call_location_for to recover original-source lines.
…wrap offset

- strings.rs unit tests: call_location_for subtracts the wrapper prefix
  line count (CJS-wrap skew correction) and maps preamble offsets to None.
- cjs_wrap unit test: wrap_commonjs_with_body_offset locates the original
  body so a wrapped body line maps back to its original-source line.
- codegen IR-assertion test: under --debug-symbols the destructure path
  emits js_set_call_location before js_require_object_coercible; default
  build emits no such CALL (byte-identical).
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Fixes issue #5247 by adding debug_source_line_offset throughout the compilation pipeline so that CommonJS-wrapped module byte/line coordinates are translated back to original-source lines in debug output. Includes emitting js_set_call_location before requireObjectCoercible throws at destructuring sites, and propagating wrapped-source metadata through CompilationContext.

Changes

CJS-wrap debug source line offset fix

Layer / File(s) Summary
Data contracts: CjsWrapDebugSource, CompilationContext fields, CompileOptions field
crates/perry/src/commands/compile/types.rs, crates/perry-codegen/src/codegen/opts.rs
Defines CjsWrapDebugSource (wrapped source + prefix line count), adds cjs_wrap_debug_sources map and debug_symbols flag to CompilationContext, and adds debug_source_line_offset: u32 to CompileOptions.
CJS wrap body offset computation
crates/perry/src/commands/compile/cjs_wrap/wrap.rs, crates/perry/src/commands/compile/cjs_wrap/mod.rs
Introduces wrap_commonjs_with_body_offset returning (String, Option<usize>) by locating the body in the wrapped output; wrap_commonjs_for_target delegates to it. Re-exports the new function and adds a unit test validating the offset mapping.
Module collection: prefix line count with createRequire delta
crates/perry/src/commands/compile/collect_modules.rs
Uses wrap_commonjs_with_body_offset to compute body-prefix newlines, measures the line delta from transform_create_require_literal_requires, and records an adjusted CjsWrapDebugSource into ctx.cjs_wrap_debug_sources when ctx.debug_symbols is active.
Compile pipeline wiring
crates/perry/src/commands/compile.rs
Sets ctx.debug_symbols = args.debug_symbols when building the context; derives each module's module_source from the stored wrapped source (or disk fallback) and debug_source_line_offset from prefix_line_count when composing per-module CompileOptions.
StringPool line offset correction
crates/perry-codegen/src/strings.rs
Adds debug_source_line_offset field and set_debug_source_line_offset setter; updates call_location_for to subtract the prefix offset from wrapped_line, returning None for coordinates inside the wrapper preamble. Extends unit tests to cover boundary cases.
Codegen: wire offset and emit js_set_call_location for destructuring
crates/perry-codegen/src/codegen/mod.rs, crates/perry-codegen/src/lower_call/native/mod.rs, crates/perry-hir/src/destructuring/pattern_binding.rs
compile_module calls set_debug_source_line_offset on the StringPool. HIR lowers object destructuring to pass span.lo.0 as a second argument to requireObjectCoercible. The native call lowerer emits js_set_call_location via emit_call_location_at before js_require_object_coercible when debug locations are enabled.
Regression test and test helper updates
crates/perry-codegen/tests/destructure_call_location.rs, crates/perry-codegen/tests/*, crates/perry/src/commands/compile/object_cache.rs
New regression test asserts js_set_call_location precedes js_require_object_coercible in IR with debug locations, and is absent without. All existing empty_opts()/entry_opts() helpers updated with debug_source_line_offset: 0.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as compile.rs
  participant Collect as collect_modules.rs
  participant Wrap as wrap_commonjs_with_body_offset
  participant Ctx as CompilationContext
  participant Codegen as compile_module
  participant Pool as StringPool
  participant Native as lower_native_method_call

  CLI->>Ctx: ctx.debug_symbols = args.debug_symbols
  CLI->>Collect: collect_modules(ctx)
  Collect->>Wrap: wrap_commonjs_with_body_offset(source)
  Wrap-->>Collect: (wrapped_source, body_offset)
  Collect->>Collect: compute prefix_line_count (with createRequire delta)
  Collect->>Ctx: cjs_wrap_debug_sources[path] = CjsWrapDebugSource { wrapped_source, prefix_line_count }
  CLI->>Codegen: compile_module(opts { module_source, debug_source_line_offset })
  Codegen->>Pool: set_debug_source_line_offset(opts.debug_source_line_offset)
  Note over Native,Pool: During IR lowering
  Native->>Pool: call_location_for(span.lo.0)
  Pool-->>Native: Some(original_line) or None
  Native->>Native: emit js_set_call_location(file, line)
  Native->>Native: emit js_require_object_coercible(value)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5250: Directly extends the same StringPool::call_location_for / js_set_call_location plumbing that this PR modifies, making the two changes closely coupled at the code level.

Poem

🐇 Hop hop, the lines were all askew,
The wrapper hid where errors grew.
I subtracted prefix, counted newlines too,
Now file:line points somewhere true!
No more null-to-object mystery ~
The offset map sets debug history. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: fixing debug-symbols source locations for destructure-of-undefined and CommonJS-wrap coordinate mapping.
Description check ✅ Passed The description comprehensively covers summary, specific changes, related issues, test plan completeness, and follows the repository template structure with all major sections properly filled.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/cjs-wrap-diag-coords

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

@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.

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/object_cache.rs (1)

168-173: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include debug_source_line_offset in object-cache key derivation.

Line 172 includes debug_locations, but opts.debug_source_line_offset is not hashed. Under debug symbols, this offset changes js_set_call_location line literals, so cache reuse can return stale objects with wrong file:line attribution.

Suggested fix
     h.field("dbgloc", if opts.debug_locations { "1" } else { "0" });
+    h.field(
+        "debug_source_line_offset",
+        &opts.debug_source_line_offset.to_string(),
+    );
🤖 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/object_cache.rs` around lines 168 - 173,
The object-cache key derivation is missing the debug_source_line_offset field
which affects js_set_call_location line literals. In the section where
h.field("dbgloc", ...) is called to hash debug_locations, add an additional
h.field() call to hash opts.debug_source_line_offset using a descriptive key
name. This ensures that toggling the debug source line offset flag will generate
a different cache key and prevent serving stale objects with incorrect file:line
attribution.
🧹 Nitpick comments (3)
crates/perry/src/commands/compile/types.rs (1)

842-851: ⚡ Quick win

Align source-text docs with the actual stored payload.

The comment on cjs_wrap_debug_sources describes storing ORIGINAL/pre-wrap text, but CjsWrapDebugSource clearly stores WRAPPED text. This mismatch is easy to cargo-cult into incorrect future usage.

♻️ Suggested doc fix
-    /// `#5247` (CJS-wrap coordinate skew): for each CommonJS module rewritten by
-    /// `cjs_wrap::wrap_commonjs_for_target`, the ORIGINAL (pre-wrap) source
-    /// text plus the number of newline characters the injected wrapper prefix
+    /// `#5247` (CJS-wrap coordinate skew): for each CommonJS module rewritten by
+    /// `cjs_wrap::wrap_commonjs_for_target`, the WRAPPED source text plus the
+    /// number of newline characters the injected wrapper prefix
     /// prepended before the original module body. Under `--debug-symbols`,
     /// codegen resolves a node's `byte_offset` (which is in WRAPPED
     /// coordinates) to a line by deducting this prefix line count and looking
-    /// up the original source — so a throw renders `at <module>:<original-line>`
+    /// up the wrapped source — so a throw renders `at <module>:<original-line>`
     /// rather than a line shifted by the preamble.

Also applies to: 859-866

🤖 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/types.rs` around lines 842 - 851, The
documentation comment for the cjs_wrap_debug_sources field incorrectly states
that it stores ORIGINAL (pre-wrap) source text, but the actual
CjsWrapDebugSource structure stores WRAPPED text. Correct the doc comment to
accurately reflect that the stored source is in WRAPPED coordinates, not
original coordinates. This includes updating the description across all related
documentation (including the section that applies to lines 859-866) to ensure
consistency and prevent future misuse of this field.
crates/perry/src/commands/compile/cjs_wrap/wrap.rs (1)

16-22: ⚡ Quick win

Avoid unconditional body-offset search in the non-debug wrapper path.

wrap_commonjs_for_target now always pays for wrapped.find(...) even when the offset is discarded. Since this path is hot, keep the fast string-only route and compute the offset only for callers that explicitly need it.

♻️ Refactor sketch
 pub(in crate::commands::compile) fn wrap_commonjs_for_target(
     source: &str,
     source_path: &Path,
     target: Option<&str>,
 ) -> String {
-    wrap_commonjs_with_body_offset(source, source_path, target).0
+    wrap_commonjs_inner(source, source_path, target, false).0
 }

 pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset(
     source: &str,
     source_path: &Path,
     target: Option<&str>,
 ) -> (String, Option<usize>) {
+    wrap_commonjs_inner(source, source_path, target, true)
+}
+
+fn wrap_commonjs_inner(
+    source: &str,
+    source_path: &Path,
+    target: Option<&str>,
+    need_body_offset: bool,
+) -> (String, Option<usize>) {
     // ... existing wrap construction ...
-    let body_offset = if body_for_iife.is_empty() {
-        None
-    } else {
-        wrapped.find(body_for_iife.as_str())
-    };
+    let body_offset = if need_body_offset && !body_for_iife.is_empty() {
+        wrapped.find(body_for_iife.as_str())
+    } else {
+        None
+    };
     (wrapped, body_offset)
 }

Also applies to: 744-753

🤖 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/wrap.rs` around lines 16 - 22, The
wrap_commonjs_for_target function currently calls wrap_commonjs_with_body_offset
which performs an unnecessary body-offset search via find(...) even though the
offset result is discarded. To optimize this hot path, refactor
wrap_commonjs_for_target to use a simpler wrapping implementation that skips the
offset computation entirely, and only have callers that explicitly need the
offset call wrap_commonjs_with_body_offset instead. This ensures the fast
string-only route avoids unnecessary processing.
crates/perry/src/commands/compile/collect_modules.rs (1)

435-435: 💤 Low value

Optional micro-optimization: Move line counting inside the conditional.

Line counting executes unconditionally but the result is only used when was_cjs_wrapped && ctx.debug_symbols (line 437). Moving this line inside that conditional would avoid the scan for non-wrapped modules and non-debug builds.

Impact is negligible (one fast byte scan per module in production), and the current placement keeps the before/after counts visually adjacent to the transform. Only worth changing if you value the minor production build efficiency over the current clarity.

♻️ Optional refactor
-let lines_before_transform = source.bytes().filter(|&b| b == b'\n').count();
 let source = transform_create_require_literal_requires(&source, &ctx.compile_packages);
 if was_cjs_wrapped && ctx.debug_symbols {
     if let Some(prefix_lines) = cjs_wrap_body_prefix_lines {
+        let lines_before_transform = source.bytes().filter(|&b| b == b'\n').count();
         let lines_after_transform = source.bytes().filter(|&b| b == b'\n').count();

Wait, that's wrong - we need to count BEFORE the transform. Let me fix:

-let lines_before_transform = source.bytes().filter(|&b| b == b'\n').count();
 let source = transform_create_require_literal_requires(&source, &ctx.compile_packages);
 if was_cjs_wrapped && ctx.debug_symbols {
     if let Some(prefix_lines) = cjs_wrap_body_prefix_lines {
+        // Note: would need to count lines before the transform above, 
+        // so this optimization requires restructuring to avoid double-scanning
         let lines_after_transform = source.bytes().filter(|&b| b == b'\n').count();

Actually, this is trickier than it looks because we need the count BEFORE the transform but the conditional check is AFTER. We'd need to either:

  1. Store the original source temporarily, or
  2. Count before transform only when the condition will be true

This makes the refactor more complex than initially assessed. Let me revise the suggestion.

[actions]
Actually, skip this comment. The refactor is more complex than it appears (would require restructuring), and the benefit is truly minimal. This doesn't meet the threshold for even an optional suggestion in a Chill review.
[scratchpad_end] -->

🤖 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.rs` at line 435, No changes
are required for this code. The reviewer has determined that while moving the
line counting operation inside the conditional (before the
lines_before_transform variable in the section near the was_cjs_wrapped check)
could theoretically optimize performance, the refactor is too complex to
implement cleanly since the count must occur before the transform but the
conditional check happens after. The minimal performance benefit does not
justify the added code complexity, so this suggestion should be disregarded.
🤖 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.

Outside diff comments:
In `@crates/perry/src/commands/compile/object_cache.rs`:
- Around line 168-173: The object-cache key derivation is missing the
debug_source_line_offset field which affects js_set_call_location line literals.
In the section where h.field("dbgloc", ...) is called to hash debug_locations,
add an additional h.field() call to hash opts.debug_source_line_offset using a
descriptive key name. This ensures that toggling the debug source line offset
flag will generate a different cache key and prevent serving stale objects with
incorrect file:line attribution.

---

Nitpick comments:
In `@crates/perry/src/commands/compile/cjs_wrap/wrap.rs`:
- Around line 16-22: The wrap_commonjs_for_target function currently calls
wrap_commonjs_with_body_offset which performs an unnecessary body-offset search
via find(...) even though the offset result is discarded. To optimize this hot
path, refactor wrap_commonjs_for_target to use a simpler wrapping implementation
that skips the offset computation entirely, and only have callers that
explicitly need the offset call wrap_commonjs_with_body_offset instead. This
ensures the fast string-only route avoids unnecessary processing.

In `@crates/perry/src/commands/compile/collect_modules.rs`:
- Line 435: No changes are required for this code. The reviewer has determined
that while moving the line counting operation inside the conditional (before the
lines_before_transform variable in the section near the was_cjs_wrapped check)
could theoretically optimize performance, the refactor is too complex to
implement cleanly since the count must occur before the transform but the
conditional check happens after. The minimal performance benefit does not
justify the added code complexity, so this suggestion should be disregarded.

In `@crates/perry/src/commands/compile/types.rs`:
- Around line 842-851: The documentation comment for the cjs_wrap_debug_sources
field incorrectly states that it stores ORIGINAL (pre-wrap) source text, but the
actual CjsWrapDebugSource structure stores WRAPPED text. Correct the doc comment
to accurately reflect that the stored source is in WRAPPED coordinates, not
original coordinates. This includes updating the description across all related
documentation (including the section that applies to lines 859-866) to ensure
consistency and prevent future misuse of this field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b2ec450f-eca6-4fb3-9468-684e7c6b6ddb

📥 Commits

Reviewing files that changed from the base of the PR and between 1cf34c4 and eaec45d.

📒 Files selected for processing (24)
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/lower_call/native/mod.rs
  • crates/perry-codegen/src/strings.rs
  • crates/perry-codegen/tests/argless_builtin_extra_args.rs
  • crates/perry-codegen/tests/class_keys_gc_root.rs
  • crates/perry-codegen/tests/constructor_recursion.rs
  • crates/perry-codegen/tests/destructure_call_location.rs
  • crates/perry-codegen/tests/large_object_barriers.rs
  • crates/perry-codegen/tests/macos_bundle_chdir_gate.rs
  • crates/perry-codegen/tests/native_proof_buffer_views.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • crates/perry-codegen/tests/shadow_slot_hygiene.rs
  • crates/perry-codegen/tests/static_symbol_hygiene.rs
  • crates/perry-codegen/tests/typed_feedback.rs
  • crates/perry-codegen/tests/typed_shape_descriptor.rs
  • crates/perry-codegen/tests/typed_shape_descriptors.rs
  • crates/perry-hir/src/destructuring/pattern_binding.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/src/commands/compile/types.rs

@proggeramlug proggeramlug merged commit 8704cca into main Jun 17, 2026
15 checks passed
@proggeramlug proggeramlug deleted the fix/cjs-wrap-diag-coords branch June 17, 2026 12:46
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