Skip to content

Compose sourcemaps in two-stage register hooks - #100

Open
STRd6 wants to merge 3 commits into
mainfrom
compose-register-sourcemaps
Open

Compose sourcemaps in two-stage register hooks#100
STRd6 wants to merge 3 commits into
mainfrom
compose-register-sourcemaps

Conversation

@STRd6

@STRd6 STRd6 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

The register/tsc and register/civet hooks compile .hera files with { inlineMap: true } — a map from generated TS/Civet back to the hera source — then transpile that output to JS without regenerating the map. Emit shifts line numbers (−85 lines for hera.hera through tsc with 0.9.8 codegen; +3 through Civet), so the stale inline map is misaligned against the JS that actually executes. breaking source attribution for the JS that actually executes. The two stages fail differently:

  • tsc path: ts.transpileModule regenerates nothing, so the original hera→TS inline map survives unchanged and is misaligned against the shifted JS — a stale map.
  • civet path: the identical flaw — the stale map comment was passed through verbatim. @danielx/civet's compile already auto-strips a trailing inline map from its input (options.upstreamSourceMap ??= sm), so the original hook — which requested no output map — emitted JS with no sourcemap at all. Missing attribution, not stale.

Consequences:

  • Stack traces through .hera files point at the wrong lines (or nowhere, on the civet path).
  • c8 coverage attribution for source/hera.hera is garbage in every version — it accidentally reported 100% under the 0.9.0 pin and drops to ~94.7% lines / 30% branches under 0.9.8, which is what blocks bumping the @danielx/hera-previous pin (see Full ECMA-262 regex literal grammar sample #99).

Reproduced before the fix: the HandlingExpressionBody handler line $1.join("").trimEnd() (source/hera.hera:147) sits at line 661 of the generated TS but line 576 of the emitted JS, and the stale map returns source: null for that JS position.

Fix

  • New source/register/sourcemap.civet: strips/decodes a trailing inline-map comment and composes two maps with @jridgewell/remapping, re-encoding inline. Also exports a hasInlineMap presence check.
  • transpileTsToJs: when the input carries an inline map, strip it, run ts.transpileModule with sourceMap: true, drop tsc's dangling //# sourceMappingURL=module.js.map comment, and attach the composed hera → JS map. Covers both the cjs and esm paths.
  • compileCivetToJs: same composition using Civet's sourceMap: true output (the civet hook had the identical flaw — it passed the stale map comment through verbatim). relies on Civet's native composition. Civet's compile auto-strips a trailing inline map from its input and adopts it as the upstream map (options.upstreamSourceMap ??= sm); requesting { inlineMap: true } then composes that upstream map with Civet's own Civet→JS map and emits a single hera→JS inline map. The hook only checks whether the input has a map (so map-free inputs stay untouched) and passes the raw source through — no manual @jridgewell/remapping step on this path.
  • Inputs without an inline map behave exactly as before.
  • @jridgewell/remapping becomes the package's first runtime dependency — required by the tsc path, which (unlike Civet) has no native map composition. Already in the tree transitively, but the dist register files are unbundled so it must be declared. @jridgewell/trace-mapping added as a devDependency for tests.

Verification

  • After the fix, decoding the composed map confirms the probe JS lines map back to source/hera.hera:147 / :160 on both the tsc and civet paths.
  • Regression tests in test/register/{tsc,civet}/transpile.civet compile source/hera.hera, assert exactly one inline map survives (the stale one is stripped), assert emit actually shifted the probe lines (so a stale map cannot pass accidentally), and assert two handler lines map back to their .hera lines.
  • End-to-end: with mocha's require hook pointed at the local dist/register/tsc, the suite passes self-bootstrapped on 0.9.8 codegen and hera.hera reports a genuine 100% statements/branches/lines.
  • pnpm build && pnpm test green at the existing 100% thresholds with the pin still at 0.9.0.

Follow-up

Once this ships in a release, bump @danielx/hera-previous to it — that restores samples/regex.hera to the benchmark table and may surface genuinely uncovered handler branches in hera.hera needing real tests.

🤖 Generated with Claude Code

The tsc and civet register hooks compile .hera files with an inline map
(hera -> TS/Civet) and then transpile to JS without regenerating the
map. Emit shifts line numbers (-85 lines for hera.hera through tsc), so
the stale map misaligns stack traces and c8 coverage attribution for
.hera sources.

transpileTsToJs and compileCivetToJs now strip the stale inline map,
run the second stage with sourceMap enabled, and attach a composed
hera -> JS map via @jridgewell/remapping (first runtime dependency;
the dist register files are unbundled). Inputs without an inline map
behave as before.

Regression tests assert that probe handler lines in the emitted JS map
back to their source/hera.hera lines on both paths, and that emit
actually shifted the lines so a stale map cannot pass accidentally.

With the fixed hook, hera.hera coverage under 0.9.8 codegen is a
genuine 100/100 (previously ~94.7% lines / 30% branches of noise),
unblocking the @danielx/hera-previous pin bump after release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e9fc2cb) to head (0fc9ee6).
⚠️ Report is 14 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #100   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines         1783      1783           
  Branches       300       300           
=========================================
  Hits          1783      1783           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale inline sourcemaps in the two-stage register hooks (register/tsc and register/civet) by composing the hera→TS/Civet map with the TS/Civet→JS map via @jridgewell/remapping, so stack traces and coverage attribution correctly point back to the original .hera source rather than the wrong line in the generated intermediate.

  • source/register/sourcemap.civet (new): extractInlineMap strips and decodes the trailing inline map comment; composedInlineMapComment composes two SourceMapInputs and re-encodes the result as a data-URI comment.
  • transpileTsToJs / compileCivetToJs: when the input carries an inline map, both functions now strip it, enable per-stage sourcemap emission, compose the maps, and append a single correct inline comment; inputs without an inline map are unaffected.
  • Tests in test/register/{tsc,civet}/transpile.civet compile source/hera.hera end-to-end and verify line-level accuracy of the composed map using @jridgewell/trace-mapping.

Confidence Score: 3/5

The sourcemap composition logic is correct and well-tested for the happy path, but the new extractInlineMap helper calls JSON.parse on decoded base64 from the source file without any error handling, which would crash the register hook — and therefore any require/import call — if a .hera file ever carries a truncated or corrupted inline map comment.

Two issues hold the score down: JSON.parse in extractInlineMap has no try/catch, turning a malformed sourcemap comment into a hard load-time crash of the register hook; and both the civet and tsc transpile paths silently discard all sourcemap information if the second-stage compiler unexpectedly omits its map after the first-stage map has already been stripped from the input, with no log or fallback. The core composition logic is sound and the end-to-end tests are thorough, but the crash path in the parser is on the critical load path for every .hera file.

source/register/sourcemap.civet (the JSON.parse in extractInlineMap) and the fallback branches in source/register/civet/transpile.civet and source/register/tsc/transpile.civet.

Important Files Changed

Filename Overview
source/register/sourcemap.civet New helper module; extractInlineMap calls JSON.parse on decoded base64 without a try/catch, which will crash the register hook at load-time if any file has a truncated/malformed inline map comment.
source/register/tsc/transpile.civet Sourcemap composition logic added; regex for stripping tsc's .js.map comment could leave a trailing \r on CRLF output, but this is a low-risk edge case. Silent map loss if sourceMapText is absent when inputMap is set.
source/register/civet/transpile.civet Composition logic mirrors the tsc path; the else result.code fallback silently drops the sourcemap if Civet returns an object without sourceMap after the input map has already been stripped.
test/register/tsc/transpile.civet Solid end-to-end sourcemap composition tests: verifies exactly one map comment survives, checks sources array, and asserts probe lines trace back to the correct .hera line.
test/register/civet/transpile.civet Civet-path composition tests mirror the tsc tests with appropriate timeout; covers both the composed-map case and the no-input-map case.
package.json Adds @jridgewell/remapping as a runtime dependency (pinned exact version 2.3.5) and @jridgewell/trace-mapping as a devDependency for tests.
build/esbuild.civet Adds source/register/sourcemap.civet to the unbundled register entry-points list — required since the dist files are not bundled and @jridgewell/remapping must be a declared dependency.

Sequence Diagram

sequenceDiagram
    participant Loader as Register Hook (CJS/ESM)
    participant T as transpileTsToJs / compileCivetToJs
    participant E as extractInlineMap
    participant C as tsc / civet.compile
    participant R as @jridgewell/remapping
    participant Out as Output JS

    Loader->>T: tsSource / civetSrc (with inline hera→TS/Civet map)
    T->>E: source string
    E-->>T: "{ code (stripped), map: heraToIntermediateMap }"
    T->>C: stripped code + sourceMap:true
    C-->>T: "{ outputText/code, sourceMapText/sourceMap }"
    T->>R: "remapping([intermediateToJsMap, heraToIntermediateMap], ()=>null)"
    R-->>T: composed heraToJsMap
    T-->>Out: js + composed inline sourcemap comment
Loading

Reviews (1): Last reviewed commit: "Compose sourcemaps in two-stage register..." | Re-trigger Greptile

Comment thread source/register/sourcemap.civet Outdated
Comment thread source/register/civet/transpile.civet Outdated
Comment thread source/register/tsc/transpile.civet Outdated
A malformed inline map comment now degrades to the no-map pass-through
instead of throwing at file-load time, and the tsc map-comment strip
tolerates CRLF emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edemaine

Copy link
Copy Markdown
Collaborator

I thought we added inline source map detection and automatic composition in Civet. Am I misremembering? Or possibly a Civet version issue? Obviously this won't work with TypeScript though.

The civet register hook reimplemented two-stage map composition with
@jridgewell/remapping, but @danielx/civet's compile already auto-strips a
trailing inline map from its input, adopts it as upstreamSourceMap, and
composes it with its own Civet->JS map when an output map is requested
(`options.upstreamSourceMap ??= sm`).

Detect whether the input carries a map (to leave map-free inputs
untouched), pass the raw source through with { inlineMap: true }, and let
Civet compose. Drops the manual strip/compose and the @jridgewell/remapping
use on this path; remapping is still required for the tsc path, which has
no native composition. Adds a hasInlineMap presence check to sourcemap.civet.

Verified: compiling source/hera.hera through the hook emits a single
composed inline map whose probe handler lines map back to source/hera.hera;
pnpm build && pnpm test green at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@STRd6

STRd6 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator Author

You're right — Civet's done this since ≥0.11.6 (upstreamSourceMap ??= sm). The hook just wasn't using it; 0fc9ee6 drops the manual @jridgewell/remapping compose and passes raw source through with { inlineMap: true }. remapping stays for the tsc path only — no Civet equivalent there, as you note.

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.

2 participants