Skip to content

Add #147: subpath binding via the exports map (--subpaths) - #153

Merged
jagguji merged 3 commits into
mainfrom
feat/147-subpath-binding
Jul 20, 2026
Merged

Add #147: subpath binding via the exports map (--subpaths)#153
jagguji merged 3 commits into
mainfrom
feat/147-subpath-binding

Conversation

@jagguji

@jagguji jagguji commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Follow-on to #104 (main-entry exports resolution). Closes #147.

Problem

Modern packages expose distinct modules per subpath@mui/material/styles, date-fns/format, per-part Radix, base-ui's ./accordion/./button, clsx's ./lite — each with its own .d.ts behind an exports["./sub"] condition. #104 resolves only the main . entry, so those subpaths were never bound.

Fix — --subpaths (unified registry, Option A)

Bind every concrete subpath too, each stamped @module("pkg/sub"), while emitting shared types once:

  • resolve.mjspackageEntries() enumerates the exports map: main . first (via the robust typesEntry), then each concrete ./sub with a resolvable types condition. Wildcard ("./*"), CSS, and package.json subpaths are skipped; entries deduped by file. resolveInput returns subEntries (suffix-based, so --from rebases them).
  • extract.mjsextractModule now walks multiple entries in ONE TypeScript program + ONE shared registry (opts.entries = [{from, entry}]). Each binding carries its own from; a type referenced from several subpaths is deduped (keyed by type.id, homed by its declaring file — not by which subpath saw it). Post-passes run once. Single-entry callers get entries=[{from,entry}] → the loop runs once → byte-identical (my safety net; the 102 goldens confirm).
  • emit.mjsno change: @module is already stamped from ir.import.from per-binding.
  • cli.mjs--subpaths flag + wiring + help.

Result

// Button.res              @module("demo") @react.component ...   (main entry)
// createTheme (fn)        @module("demo/styles") external ...    (the ./styles subpath!)
// SubpathBindingTypes.res  type theme = { primary: string, spacing: float }  // shared — emitted ONCE

Verified live on clsx (--subpaths detects clsx/lite).

Off by default

--subpaths is opt-in, so existing single-entry output — and the benchmark (byte-identical on all 9 packages) — is unchanged. The survey that drove this: hono (73 subpaths), base-ui (42), clsx (1) have real code subpaths, so default-on would explode those baselines; the pure-CSS subpaths (day-picker, react-rating) are auto-skipped by the types-condition filter.

Verification

  • 102 goldens match + compile; 4 new smoke checks (per-binding @module, shared-type dedup, enumerator).
  • Benchmark byte-identical (flag off).
  • Fixture: subpath-binding (main Button + ./styles createTheme sharing Theme).

Documented limitation

A symbol exported under the same name by multiple subpaths (clsx's clsx in both . and ./lite) binds once, main . wins. This is correct for re-exports and for different-named subpaths (base-ui/@mui — the common case); the same-name-across-subpaths case keeps the main binding rather than disambiguating (a possible future refinement).

🤖 Generated with Claude Code

jagguji and others added 3 commits July 20, 2026 19:04
…gistry)

Modern packages expose distinct modules per subpath (@mui/material/styles, per-part
Radix, base-ui ./accordion/./button, clsx ./lite), each with its own .d.ts behind an
exports["./sub"] condition. #104 bound only the main `.` entry; --subpaths now binds
every concrete subpath too, each stamped @module("pkg/sub"), with shared types
emitted ONCE.

- resolve.mjs: packageEntries() enumerates the exports map — main `.` first (via the
  robust typesEntry), then each concrete `./sub` with a resolvable `types` condition
  (skips `./*` wildcards, CSS, package.json), deduped by file. resolveInput returns
  subEntries (suffix-based so --from rebases them).
- extract.mjs: extractModule now walks MULTIPLE entries in ONE program + ONE shared
  registry (opts.entries = [{from, entry}]); each binding carries its own `from`; a
  type referenced from several subpaths is deduped by type.id, homed by its declaring
  file. Post-passes run once. Single-entry callers get entries=[{from,entry}] -> the
  loop runs once -> byte-identical (verified: 102 goldens unchanged).
- emit.mjs: no change — @module already stamped from ir.import.from per-binding.
- cli.mjs: --subpaths flag + entries wiring + help.

Off by default, so existing output and the benchmark are byte-identical. A symbol
re-exported under the same name from multiple subpaths binds once (main `.` first);
different-named subpaths (the common case) each bind with their subpath specifier.

Fixture: subpath-binding (main Button + ./styles createTheme sharing Theme). 4 smoke
checks. Verified live on clsx (./lite).

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

Review follow-up on #147. The 4 CodeQL alerts were PRE-EXISTING (introduced in the
v1.0.0 commit 6aea704, verified via git) and only re-attributed to this PR because
packageEntries shifted their line numbers into the changed region. Fixing them here
since they gate the merge (and pkg.pr.new):

- ReDoS (high): the scoped-package-name regex `/(@[^/]+\/[^@]+)@.*/` is a polynomial
  backtracking shape. Replaced with linear string ops (the version `@` is just the
  first `@` after the scope's `/`). Verified byte-identical to the old regex across
  scoped/unscoped/versioned specs.
- shell-injection (medium x3): the two `execSync(\`npm install ${spec} …\`)` calls
  interpolated input into a shell string. Switched to `execFileSync('npm', [args])`
  (no shell) with REACT_TYPE_DEPS as an argv array — a package spec can no longer be
  interpreted as a shell command. Behaviour is identical (no shell features were used).

Also, review points 2 & 3 on the subpath feature itself:
- #147 point 2: a same-name DIFFERENT symbol across subpaths (clsx `.` vs `./lite`
  `clsx`) is now REPORTED as skipped (`subpath-name-shadowed`) instead of dropped
  silently — a benign re-export (SAME symbol) stays silent. Tracked via a per-name
  first-symbol map.
- #147 point 3: fixture extended with a `./button` subpath that RE-EXPORTS the main
  Button (same symbol -> binds once as @module("demo"), no skip) + an `alt.d.ts` with
  a different Button for the collision path. 2 new smoke checks cover both.

npm-install paths aren't exercised by the offline suite; the change is a mechanical
shell->argv refactor. Goldens/compile/benchmark all unchanged.

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

Copy link
Copy Markdown

Benchmark: ✅ PASS

Package Compile Diff vs baseline usable review broken Verdict
@juspay/blend-design-system@0.0.36 identical 102 5 0 ✅ PASS
@juspay/blend-design-system@0.0.37-beta.8 identical 215 7 0 ✅ PASS
react-day-picker@10.0.1 identical 19 7 0 ✅ PASS
react-tooltip@6.0.7 identical 1 0 0 ✅ PASS
react-markdown@10.1.0 identical 0 2 0 ✅ PASS
@smastrom/react-rating@1.5.0 identical 1 0 0 ✅ PASS
clsx@2.1.1 identical 0 0 0 ✅ PASS
hono@4.12.25 identical 0 0 0 ✅ PASS
@base-ui-components/react@1.0.0-rc.0 identical 174 21 0 ✅ PASS

@pkg-pr-new

pkg-pr-new Bot commented Jul 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@juspay/rescript-bindgen@153

commit: 3597baf

@jagguji
jagguji merged commit 56ddb83 into main Jul 20, 2026
11 of 12 checks passed
@jagguji
jagguji deleted the feat/147-subpath-binding branch July 20, 2026 14:56
jagguji added a commit that referenced this pull request Jul 22, 2026
The repo has **9 open CodeQL alerts on `main`** (visible in the
default-branch scan). None come from any feature PR — they get
re-attributed onto large-regeneration PRs (#153, #156) by CodeQL's
"changes too large" re-scan, showing up as a red aggregate **CodeQL**
check even though the `Analyze` jobs pass. This clears the root debt so
that check goes green.

## Fixes

**Code — `js/file-system-race` (high ×3) + `js/identity-replacement`
(medium)**
- `test/lib/diff.mjs` — `readdirSync(dir, { withFileTypes: true })`
instead of `readdir` + a separate `statSync` (the stat is a
check-then-read TOCTOU: the file could vanish between the two syscalls).
- `src/cli.mjs` — `unlink` directly inside the existing try/catch
instead of `existsSync`-then-`unlink` (the check-then-unlink is the
race; the catch already handles "already gone").
- `benchmark/run.mjs` — read the prior lock-stamp via try/catch instead
of `existsSync`-then-`readFileSync`; and drop the `.replace(/^_/, '_')`
in `slugOf`, which was an **identity no-op** (`"@s/p"` → `"_s_p"`
already starts with `_`).

**Workflows — `actions/missing-workflow-permissions` (medium ×3) +
`actions/unpinned-tag` (medium ×2)**
- `ci.yml` — add `permissions: contents: read` to the `test` / `compile`
/ `fixture-guard` jobs (least-privilege; the `preview` job already
declared its own).
- `benchmark.yml` + `yama-review.yml` — pin
`marocchino/sticky-pull-request-comment` and `juspay/yama` to their
release commit SHAs, with the `# v3` / `# v2.7.1` version as trailing
comments (the CodeQL-recommended form). *Note: `yama-review.yml` is
generated by juspay/yama's setup script, so a future regen may need the
pin re-applied.*

## Safety
All behaviour-preserving — verified: **104 goldens match + compile**,
**benchmark byte-identical** (the `slugOf` and lock-stamp changes are
proven by the baselines still resolving), and the workflows parse. No
`src/` runtime logic changed (the `cli.mjs` edit is the `--clean`
stale-file removal, same effect).

After this merges, the pre-existing alerts clear repo-wide and the red
**CodeQL** check on #156 (and future large-regen PRs) goes green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Subpath binding: generate per-subpath @module("pkg/sub") from the exports map (@mui/material/styles, date-fns/*, per-part Radix)

1 participant