Visitar URL original
fix: extract declarator names with bracket-depth tracking by daresTheDevil · Pull Request #513 · unjs/unimport · GitHub
Skip to content

fix: extract declarator names with bracket-depth tracking - #513

Open
daresTheDevil wants to merge 4 commits into
unjs:mainfrom
daresTheDevil:fix/generic-type-param-leak
Open

daresTheDevil wants to merge 4 commits into
unjs:mainfrom
daresTheDevil:fix/generic-type-param-leak

Conversation

@daresTheDevil

@daresTheDevil daresTheDevil commented Mar 21, 2026 •

Copy link
Copy Markdown

Summary

Fixes #502. Replaces the JS_RESERVED_WORDS blocklist from #504 with a bracket-aware declarator name extractor that properly handles generic type parameters.

The problem: mlly's regex parser leaks identifiers from generic type parameters into exp.names. For example, export const fn = <T extends Record<string, Ref>>(...) => {} produces names: ["fn", "Ref"] because the comma inside Record<string, Ref> is misinterpreted as a variable declaration separator. The reserved words blocklist only catches JS keywords, not user-defined types like Ref, Foo, or single-letter type params like B.

The fix: extractDeclaratorNames() re-parses exp.code with bracket-depth tracking (<>, (), {}, []) to extract only names from the LHS of top-level declarators. Commas inside nested brackets are correctly ignored.

Handles all reported patterns:

  • export const fn = <T extends Record<string, Ref>>(...) => {} → ["fn"] not ["fn", "Ref"]
  • export const fn = <A, B extends Foo<Bar>>(...) => {} → ["fn"] not ["fn", "B"]
  • export const FOO = 'foo', BAR = 'bar' → ["FOO", "BAR"] (preserved)

For function/class/enum declarations, falls back to exp.names as-is since mlly handles those correctly.

Credit to @iandfox for tracing the original regression to e908b92, @cernymatej for identifying the upstream mlly issue, @JonSpeare for the clearest synthesis of the two-part bug, and @dargmuesli for reporting the remaining generic type parameter edge case after #504 shipped.

Test plan

  • New regression test: generic type params don't leak as exports (#502)
  • Existing test: reserved words still filtered (via extractDeclaratorNames returning only valid LHS names)
  • Existing test: comma-separated declarations preserved
  • Full test suite passes (190/190)

Summary by CodeRabbit

  • New Features

    • Added generic utilities for store handling, shallow object merging, resizable layouts, and sample constant exports.
  • Improvements

    • More robust export detection to correctly discover names in complex declarations and initializers.
  • Tests

    • Updated snapshots and added tests to include the new utilities and verify the improved export discovery behavior.

Replace the JS_RESERVED_WORDS blocklist with a bracket-aware declarator
name extractor. mlly's regex parser leaks identifiers from generic type
parameters (e.g. `Record<string, Ref>`) into `exp.names` because it
misinterprets commas inside angle brackets as variable declaration
separators.

The new `extractDeclaratorNames()` function tracks nesting depth of
`<>`, `()`, `{}`, and `[]` to only extract names from the LHS of
top-level declarators. This correctly handles:

- Generic type params: `export const fn = <T extends Record<string, Ref>>(...) => {}`
- Multiple type params: `export const fn = <A, B extends Foo<Bar>>(...) => {}`
- Comma-separated declarations: `export const FOO = 'foo', BAR = 'bar'`
- Function/class/enum declarations (unchanged, uses exp.names as-is)

Closes unjs#502
@coderabbitai

coderabbitai Bot commented Mar 21, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Refactors export-name detection to re-parse export const|let|var declarators with bracket/string awareness, and adds a new test-only composable (playground/composables/generic-exports.ts) containing several generic and multi-declarator exports exercised by updated tests and DTS snapshots.

Changes

Cohort / File(s) Summary
New Generic Utilities
playground/composables/generic-exports.ts
Adds useGenericStore, mergeObjects, useResizable and multi-declarator exports (uninitFoo, uninitBar, compA, compB, compC, compD) to create export-edge-case inputs for the scanner.
Export Declaration Parsing Refactor
src/node/scan-dirs.ts
Removes reserved-word filtering; introduces extractDeclaratorNames(code) which scans after export tracking <>(){}[] and strings to extract top-level declarator LHS identifiers for `const
Type Declaration Tests
test/dts.test.ts
Updates inline DTS snapshot to include new global const entries for exports from generic-exports.ts.
Export Scanning Tests
test/scan-dirs.test.ts
Expands snapshots and adds tests validating detection of multi-declarator exports, exclusion of generic/arrow parameter names, and handling of < comparison expressions inside initializers.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested Reviewers

  • antfu

Poem

🐰 I hopped through code with careful paws and eyes,

Counted braces, quotes, and commas, none disguised.
Tamed generics, ternaries, and multi-decl art,
Brought each export home — precise, neat, and smart. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: extract declarator names with bracket-depth tracking' accurately describes the main technical change—implementing bracket-aware parsing to correctly extract export declarator names.
Linked Issues check ✅ Passed The PR successfully addresses #502 by implementing extractDeclaratorNames() with bracket-depth tracking to distinguish top-level declarators from nested constructs, preventing parameter names from leaking as exports.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the export scanning issue: a new test fixture module demonstrating edge cases, the core fix in scan-dirs.ts, and corresponding test updates—no unrelated modifications present.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/scan-dirs.test.ts (1)

310-327: Please pin the original options leak too.

This test covers generic angle-bracket leakage, but issue #502 was reported against commas inside arrow-parameter defaults/destructuring. Adding a useResizable-style regression would protect the original failure mode as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/scan-dirs.test.ts` around lines 310 - 327, Add a second regression case
to the test that pins the original "options" leak by calling scanExports for the
composable that previously failed (the useResizable-style example) and asserting
exports include the expected export (useResizable) but do NOT include the leaked
identifier "options"; reuse the same pattern/variables (scanExports, exports,
names) as in the existing generic test and add
expect(names).toContain('useResizable') and
expect(names).not.toContain('options').
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/node/scan-dirs.ts`:
- Around line 46-65: The scanner incorrectly treats every '<' as a generic-angle
opener which hides later declarators; update the logic around
angleDepth/isTopLevel/inValue so '<' and '>' are ignored once we are scanning a
RHS (inValue === true) and also ignore them when inside string/template/comment
contexts (if such flags exist); specifically, in the block that updates
angleDepth (the angleDepth variable and the isTopLevel computation used by the
'=' and ',' branches), only increment/decrement angleDepth when not inValue and
not inside string/template/comment, so commas in expressions like `a = foo < bar
? 1 : 2, b = 3` or string literals like `a = "<", b = 1` are recognized as
top-level separators and names (RE_IDENTIFIER, current, names, inValue) are
parsed correctly.
- Around line 35-80: The loop in scan-dirs.ts can produce partial export lists;
change the parsing to (1) introduce a boolean flag (e.g.
sawUnsupportedDeclarator) that is set whenever a declarator contains a
non-plain-identifier pattern (detect top-level ':' for type annotations, '{' or
'[' for destructuring, '...' for rest, or any character in current that fails
RE_IDENTIFIER when trimmed) and cause the function to return undefined if that
flag is true at the end, and (2) handle top-level commas as end-of-declarator
events the same way '=' is handled by treating the current token before a
top-level ',' as a candidate name (trim and test with RE_IDENTIFIER and push
into names) before resetting current and inValue; keep using the existing
variables (rest, current, inValue, names, RE_IDENTIFIER,
angleDepth/parenDepth/braceDepth/bracketDepth) and do not attempt to fix
string/comment literal parsing in this change.

---

Nitpick comments:
In `@test/scan-dirs.test.ts`:
- Around line 310-327: Add a second regression case to the test that pins the
original "options" leak by calling scanExports for the composable that
previously failed (the useResizable-style example) and asserting exports include
the expected export (useResizable) but do NOT include the leaked identifier
"options"; reuse the same pattern/variables (scanExports, exports, names) as in
the existing generic test and add expect(names).toContain('useResizable') and
expect(names).not.toContain('options').

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11b3d7d5-9b22-422b-8e86-a63746d936f1

📥 Commits

Reviewing files that changed from the base of the PR and between 3b0a712 and 1687e9f.

📒 Files selected for processing (4)
  • playground/composables/generic-exports.ts
  • src/node/scan-dirs.ts
  • test/dts.test.ts
  • test/scan-dirs.test.ts

Comment thread src/node/scan-dirs.ts
Comment thread src/node/scan-dirs.ts Outdated
…ractDeclaratorNames

Address CodeRabbit review findings on PR unjs#513:

- Angle bracket heuristic: distinguish generics (`Record<K, V>`) from
  comparisons (`1 < 2`) by checking the preceding non-whitespace character.
  Generics follow identifiers, `=`, `)`, or `>`. Comparisons follow digits.
- String literal tracking: skip `<`, `,`, and other structural chars inside
  string literals to prevent depth corruption.
- Multi-declarator capture: `export let foo: string, bar = 1` now correctly
  exports both `foo` and `bar`. Type annotations are stripped before the
  identifier check.
- Pin original unjs#502 regression with explicit `useResizable` test case.

@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: 1

🧹 Nitpick comments (2)
src/node/scan-dirs.ts (2)

14-14: Hoist regex patterns to module scope to avoid re-compilation.

ESLint flags multiple regex literals that are re-created on every function call. Moving them to module scope improves performance, especially when scanning many files.

♻️ Proposed refactor
+const RE_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
+const RE_EXPORT_DECL = /^export\s+(?:default\s+)?(?:const|let|var)\s+/
+const RE_WHITESPACE = /\s/
+const RE_GENERIC_OPENER_PREV = /[a-zA-Z_$)>=]/
+const RE_TYPE_ANNOTATION = /:.*$/
 
-const RE_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
-
 function extractDeclaratorNames(code: string): string[] | undefined {
-  const match = code.match(/^export\s+(?:default\s+)?(?:const|let|var)\s+/)
+  const match = code.match(RE_EXPORT_DECL)
   // ...
-      while (k >= 0 && /\s/.test(rest[k])) k--
+      while (k >= 0 && RE_WHITESPACE.test(rest[k])) k--
       // ...
-      if (/[a-zA-Z_$)>=]/.test(prev))
+      if (RE_GENERIC_OPENER_PREV.test(prev))
       // ...
-      const name = current.trim().replace(/:.*$/, '').trim()
+      const name = current.trim().replace(RE_TYPE_ANNOTATION, '').trim()

Also applies to: 30-30, 69-69, 71-71, 86-86, 96-96, 110-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/scan-dirs.ts` at line 14, Several regex literals used inside
functions are being re-created on every call; hoist them to module scope to
avoid repeated compilation by replacing inline regexes with module-level
constants (e.g., keep RE_IDENTIFIER and add new constants such as RE_EXT_JSX/TS,
RE_IMPORT, RE_EXPORT, RE_COMMENT, RE_REQUIRE, etc.) and update all usages in the
functions (the regex instances referenced around the existing RE_IDENTIFIER and
the ones at the commented positions ~30, ~69, ~71, ~86, ~96, ~110) to use these
constants instead; ensure names are descriptive, declared once at top of
src/node/scan-dirs.ts, and preserve existing flags and patterns exactly.

84-115: Partial returns for mixed declarators bypass fallback logic.

The function silently returns incomplete results when destructuring patterns are mixed with simple declarators. For example, export const { a } = obj, b = 1 returns ['b'] instead of falling back to exp.names. This occurs because destructuring patterns (enclosed in {} or []) fail the RE_IDENTIFIER test and are skipped, while the function still returns the remaining valid names as a truthy array. The ?? fallback on line 254 only triggers if the function returns undefined, not if it returns a partial list.

Consider whether this behavior is acceptable (potentially safer than falling back to a polluted exp.names) or if the function should return undefined when any unsupported declarator is encountered to force fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/scan-dirs.ts` around lines 84 - 115, The parser currently collects
partial names into the names array even when some declarators are unsupported
(destructuring) causing a partial truthy return; change the logic so that when a
declarator fails RE_IDENTIFIER (i.e. a destructuring pattern is encountered) you
set a flag (e.g. hadUnsupportedDeclarator) during the '=' and ',' handling
branches and in the trailing-name handling, and at the end return undefined if
that flag is true instead of returning the partial names array; ensure this
touches the code paths that use current/inValue/isTopLevel/RE_IDENTIFIER/names
so mixed declarators cause the function to return undefined (allowing the
existing fallback to exp.names) rather than a partial list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/node/scan-dirs.ts`:
- Around line 67-73: The current heuristic increments angleDepth on encountering
'<' when the previous non-space char matches /[a-zA-Z_$)>=]/, which incorrectly
treats identifier-preceded comparison operators (e.g., foo < bar) as an opener;
update the condition in the ch === '<' branch to additionally inspect the next
non-space character (using rest and i) and only increment angleDepth if that
next non-space char looks like the start of a generic/type parameter or JSX
(e.g., matches /[a-zA-Z_$<(.\[]/ or similar) rather than a digit or operator, so
angleDepth is not raised for binary comparisons; refer to the existing variables
angleDepth, ch, prev, rest, i when making this change.

---

Nitpick comments:
In `@src/node/scan-dirs.ts`:
- Line 14: Several regex literals used inside functions are being re-created on
every call; hoist them to module scope to avoid repeated compilation by
replacing inline regexes with module-level constants (e.g., keep RE_IDENTIFIER
and add new constants such as RE_EXT_JSX/TS, RE_IMPORT, RE_EXPORT, RE_COMMENT,
RE_REQUIRE, etc.) and update all usages in the functions (the regex instances
referenced around the existing RE_IDENTIFIER and the ones at the commented
positions ~30, ~69, ~71, ~86, ~96, ~110) to use these constants instead; ensure
names are descriptive, declared once at top of src/node/scan-dirs.ts, and
preserve existing flags and patterns exactly.
- Around line 84-115: The parser currently collects partial names into the names
array even when some declarators are unsupported (destructuring) causing a
partial truthy return; change the logic so that when a declarator fails
RE_IDENTIFIER (i.e. a destructuring pattern is encountered) you set a flag (e.g.
hadUnsupportedDeclarator) during the '=' and ',' handling branches and in the
trailing-name handling, and at the end return undefined if that flag is true
instead of returning the partial names array; ensure this touches the code paths
that use current/inValue/isTopLevel/RE_IDENTIFIER/names so mixed declarators
cause the function to return undefined (allowing the existing fallback to
exp.names) rather than a partial list.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61491be4-0bb3-49ab-85d2-37890d4029f7

📥 Commits

Reviewing files that changed from the base of the PR and between 1687e9f and 5ef92a5.

📒 Files selected for processing (4)
  • playground/composables/generic-exports.ts
  • src/node/scan-dirs.ts
  • test/dts.test.ts
  • test/scan-dirs.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • playground/composables/generic-exports.ts

Comment thread src/node/scan-dirs.ts
Address second CodeRabbit review on PR unjs#513:

- Adjacency-based angle bracket detection: generics are always directly
  attached to the preceding token (`Record<`, `foo<T>`) while comparisons
  have whitespace (`foo < bar`, `1 < 2`). The one exception — arrow fn
  generics (`= <T>`) — is handled by checking for `=` as last non-ws char.
- Unsupported declarator bailout: if any declarator uses destructuring or
  a pattern that can't be parsed as a plain identifier, return undefined
  to fall back to exp.names instead of silently returning partial results.
- Hoist regex patterns to module scope to avoid re-creation per call.
- Add test for identifier-preceded comparison (`_someNum < 100`).

@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: 1

🧹 Nitpick comments (1)
src/node/scan-dirs.ts (1)

93-99: ESLint style violations in bracket depth tracking.

Static analysis flags multiple style issues: line 93 has two statements, and lines 94-99 are missing newlines after if and curly brackets per the project's antfu/if-newline and antfu/curly rules.

✨ Proposed style fixes
-    else if (ch === '>' && angleDepth > 0) { angleDepth-- }
-    else if (ch === '(') parenDepth++
-    else if (ch === ')') parenDepth = Math.max(0, parenDepth - 1)
-    else if (ch === '{') braceDepth++
-    else if (ch === '}') braceDepth = Math.max(0, braceDepth - 1)
-    else if (ch === '[') bracketDepth++
-    else if (ch === ']') bracketDepth = Math.max(0, bracketDepth - 1)
+    else if (ch === '>' && angleDepth > 0) {
+      angleDepth--
+    }
+    else if (ch === '(') {
+      parenDepth++
+    }
+    else if (ch === ')') {
+      parenDepth = Math.max(0, parenDepth - 1)
+    }
+    else if (ch === '{') {
+      braceDepth++
+    }
+    else if (ch === '}') {
+      braceDepth = Math.max(0, braceDepth - 1)
+    }
+    else if (ch === '[') {
+      bracketDepth++
+    }
+    else if (ch === ']') {
+      bracketDepth = Math.max(0, bracketDepth - 1)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/scan-dirs.ts` around lines 93 - 99, Split the combined statements
and apply the project's if-newline and curly rules in the block that updates
angleDepth, parenDepth, braceDepth, and bracketDepth when processing ch: ensure
each conditional uses its own line (no two statements on one line), add a
newline immediately after each if keyword, and wrap single-line branches in
braces so e.g. the handlers for '>', '(', ')', '{', '}', '[', ']' are each
expressed as separate if (...) { ... } blocks and the '>' case decrements
angleDepth on its own line instead of sharing a line with another statement.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/node/scan-dirs.ts`:
- Around line 58-66: The string-closing logic in the loop using inString
incorrectly treats quotes preceded by a single backslash as always escaped
(rest[i - 1] !== '\\'), failing when that backslash is itself escaped; update
the check in the block that references inString, rest, i, and ch to count
consecutive backslashes immediately before index i and only treat the quote as
escaped when the count is odd, so if the count is even the quote closes the
string; adjust the condition in the if (inString) branch where you test the
closing quote accordingly.

---

Nitpick comments:
In `@src/node/scan-dirs.ts`:
- Around line 93-99: Split the combined statements and apply the project's
if-newline and curly rules in the block that updates angleDepth, parenDepth,
braceDepth, and bracketDepth when processing ch: ensure each conditional uses
its own line (no two statements on one line), add a newline immediately after
each if keyword, and wrap single-line branches in braces so e.g. the handlers
for '>', '(', ')', '{', '}', '[', ']' are each expressed as separate if (...) {
... } blocks and the '>' case decrements angleDepth on its own line instead of
sharing a line with another statement.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5bde813-53ab-4300-865a-06ba336fee7a

📥 Commits

Reviewing files that changed from the base of the PR and between 5ef92a5 and 331e34f.

📒 Files selected for processing (4)
  • playground/composables/generic-exports.ts
  • src/node/scan-dirs.ts
  • test/dts.test.ts
  • test/scan-dirs.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/dts.test.ts
  • playground/composables/generic-exports.ts
  • test/scan-dirs.test.ts

Comment thread src/node/scan-dirs.ts
- Count consecutive backslashes before a closing quote — only treat the
  quote as escaped when the backslash count is odd (`"\\"` closes,
  `"\\\"` does not).
- Apply antfu/curly and antfu/if-newline lint rules to all bracket depth
  handlers and conditional branches.

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

🧹 Nitpick comments (1)
src/node/scan-dirs.ts (1)

14-18: Optional: Consider simplifying regex patterns per ESLint hints.

ESLint suggests using \w and the i flag, but note that \w doesn't include $ (valid in JS identifiers), so the current explicit character classes are more precise. These are cosmetic and safe to defer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/scan-dirs.ts` around lines 14 - 18, ESLint hinted you can simplify
identifier regex; replace RE_IDENTIFIER with a pattern that uses \w but still
allows $ and disallows leading digits: change to /^[A-Z_$][\w$]*$/i (this
preserves $ and forbids a digit as the first char while using the i flag to
cover case), and leave RE_EXPORT_DECL, RE_TYPE_ANNOTATION, RE_GENERIC_PREV and
RE_WHITESPACE as-is unless you want further cosmetic changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/node/scan-dirs.ts`:
- Around line 14-18: ESLint hinted you can simplify identifier regex; replace
RE_IDENTIFIER with a pattern that uses \w but still allows $ and disallows
leading digits: change to /^[A-Z_$][\w$]*$/i (this preserves $ and forbids a
digit as the first char while using the i flag to cover case), and leave
RE_EXPORT_DECL, RE_TYPE_ANNOTATION, RE_GENERIC_PREV and RE_WHITESPACE as-is
unless you want further cosmetic changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b504ed3-e742-45c6-a817-997db399e2b8

📥 Commits

Reviewing files that changed from the base of the PR and between 331e34f and 34eff05.

📒 Files selected for processing (1)
  • src/node/scan-dirs.ts

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.

v5.7.0: arrow function parameters parsed as exported declarations

1 participant