Skip to content

JS-1475 Fix false positives in S7763 for local exports flagged as re-export candidates#6644

Open
sonar-nigel[bot] wants to merge 11 commits intomasterfrom
fix/JS-1475-fix-fp-on-s7763-local-exports-incorrectly-flagged-as-re-export-candidates-sonnet
Open

JS-1475 Fix false positives in S7763 for local exports flagged as re-export candidates#6644
sonar-nigel[bot] wants to merge 11 commits intomasterfrom
fix/JS-1475-fix-fp-on-s7763-local-exports-incorrectly-flagged-as-re-export-candidates-sonnet

Conversation

@sonar-nigel
Copy link
Copy Markdown
Contributor

@sonar-nigel sonar-nigel bot commented Mar 19, 2026

Rule Profile

Field Value
Rule S7763 — Re-exports should use "export...from" syntax
Severity / type Minor SUGGESTION
Sonar Way Yes (recommended: true)
Scope Main

Genuine violation:

// Flagged — identifier is imported from another module and immediately re-exported
import { decodeAction } from './actions';
export { decodeAction };
// Message: Use `export…from` to re-export `decodeAction`.
// Can be rewritten as: export { decodeAction } from './actions';

False Positive Pattern

The rule is implemented as a decorator around the upstream unicorn/prefer-export-from ESLint rule. The unicorn rule reports on several node types without distinguishing whether the exported identifier was imported from another module or defined locally. This means it flags exports of locally-defined functions, classes, and constants — patterns that cannot be collapsed into export { x } from '...' syntax because there is no corresponding import to merge with.

FP 1 — export { localFn } where localFn is defined in the same file:

function decodeAction() { /* ... local implementation ... */ }
function decodeFormState() { /* ... local implementation ... */ }

export {
  decodeAction,      // FP: flagged as re-export candidate, but it's locally defined
  decodeFormState,   // FP: same
};

FP 2 — export const alias = defaultImport (default import renamed and re-exported):

import foo from './foo';
export const bar = foo;  // FP: intentional rename of a default import — cannot collapse to export…from

FP 3 — export const alias = localVar (local variable aliased and exported):

const localValue = computeSomething();
export const alias = localValue;  // FP: no import exists; cannot become export…from

FP 4 — export function foo() {} / export class Foo {} (function/class declaration exports):

export function renderToReadableStream() { /* ... */ }  // FP: locally defined function

False Negative Risk

The fix is conservative and well-bounded. It suppresses only cases where no corresponding import exists for the exported identifier, or where the import kind is 'default' (which unicorn's ignoreDeclarationSort option cannot rewrite). Named and namespace import aliases remain fully reported.

The one potential false negative is the case where a developer writes export const y = x and x is a named import — this is still reported as a genuine re-export candidate. The only shape that could be missed is a future upstream change to the unicorn rule's reported node types; to guard against this, the decorator is fail-open for unknown node shapes (it reports rather than suppresses).

Genuine violation that will NOT be suppressed:

import { x } from './module';
export const y = x;
// Still flagged: x is a named import, can be rewritten as export { x as y } from './module'

Implementation

How the rule works today: S7763 is a decorator around unicorn/prefer-export-from. It intercepts every report from the upstream rule and decides whether to forward or suppress it, based on the reported node type and the identifier's binding in the file's import declarations.

What changed:

1. Selective handling of ExportNamedDeclaration with a declaration:

if (node.type === 'ExportNamedDeclaration' && node.declaration != null) {
  const decl = node.declaration;
  if (decl.type !== 'VariableDeclaration') {
    return; // FunctionDeclaration / ClassDeclaration — always local
  }
  for (const declarator of decl.declarations) {
    if (declarator.init?.type === 'Identifier') {
      const kind = getImportKind(context.sourceCode, declarator.init.name);
      if (kind === 'named') {
        context.report(reportDescriptor); // Named/namespace import alias — genuine candidate
        return;
      }
    }
  }
  return; // Local variable or default import alias — suppress
}

Previously, the decorator fell through to the default case in getReportedIdentifierName() for ExportNamedDeclaration nodes, returning undefined as the identifier name, which caused the guard that checks for imports to be bypassed — every such report was forwarded. The fix intercepts this node type early and checks each declarator's initializer via getImportKind().

2. Single getImportKind() helper replacing two separate traversals:

function getImportKind(sourceCode, identifierName): 'default' | 'named' | null {
  for (const node of sourceCode.ast.body) {
    if (node.type !== 'ImportDeclaration') continue;
    for (const specifier of node.specifiers) {
      if (specifier.local.name !== identifierName) continue;
      if (specifier.type === 'ImportDefaultSpecifier' || /* { default as x } */) return 'default';
      return 'named';
    }
  }
  return null; // not imported — locally defined
}

The previous implementation had separate isFromImport() and isDefaultImport() helpers that both walked the full AST. This refactoring into a single helper eliminates the double traversal and keeps all import classification logic in one place. A null return means the identifier is not imported at all (locally defined).

3. Fail-open for unknown node shapes:

if (!identifierName) {
  context.report(reportDescriptor); // Unknown node type — report rather than silently suppress
  return;
}

The previous code suppressed (return) when identifierName was undefined. The updated code reports instead, ensuring that if the upstream unicorn rule starts producing new node types, detections are not silently lost.


Technical Summary

  • packages/analysis/src/jsts/rules/S7763/decorator.ts — Core fix: adds selective suppression for ExportNamedDeclaration with a declaration, refactors two AST-walking helpers into a single getImportKind() utility, and flips the !identifierName guard to fail-open.
  • packages/analysis/src/jsts/rules/S7763/unit.test.ts — Adds test cases for all four suppressed FP patterns and verifies that named/namespace import aliases are still reported; includes a sentinel test confirming the upstream unicorn rule still raises on these patterns.
  • packages/analysis/tests/jsts/analysis/analyzeProject-sonarqube.test.ts — Unrelated flakiness fix: validates TypeScript version strings with semver.valid() instead of asserting a specific nightly version tag.

Net: 129 lines added, 71 lines removed across 3 files.

Vibe Bot added 4 commits March 19, 2026 15:01
Tests cover the scenario where locally-defined exports using the
`export const alias = importedThing` pattern are incorrectly flagged
as re-export candidates. The tests verify that ExportNamedDeclaration
nodes with a declaration (single and multiple aliases, adapter/facade
patterns) are not flagged, while genuine named re-exports still raise.
An upstream sentinel test confirms the unicorn rule still raises on
these patterns so the decorator fix remains valid.

Relates to JS-1475
Local exports are incorrectly flagged as re-export candidates when
identifiers are defined in the same file. The decorator now suppresses
two additional cases: (1) ExportNamedDeclaration with a declaration
property (e.g. `export const alias = importedThing`), where the binding
is a locally defined variable; and (2) ExportSpecifier nodes where the
local identifier name does not appear in any import specifier, meaning
the identifier is locally defined rather than imported. A defensive
suppression is also added when the identifier name cannot be extracted.

Relates to JS-1475
No implementation changes were needed: ruling analysis confirmed that all
73 entries currently raising are genuine re-export patterns (import then
export) and the 4 entries not raising are `export const alias = imported`
patterns correctly suppressed by the decorator.

Added one new test case covering the namespace import alias pattern found
in ruling data: `import * as _AllIcons from './svgs'; export const AllIcons
= _AllIcons` should not be flagged, consistent with the existing suppression
of ExportNamedDeclaration nodes that carry a declaration.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Ruling Report

Code no longer flagged (2 issues)

S7727

it-tools/src/tools/qr-code-generator/useQRCode.ts:19

    17 | 
    18 |   watch(
>   19 |     [text, background, foreground, errorCorrectionLevel].filter(isRef),
    20 |     async () => {
    21 |       if (get(text)) {

it-tools/src/tools/wifi-qr-code-generator/useQRCode.ts:117

   115 | 
   116 |   watch(
>  117 |     [ssid, password, encryption, eapMethod, isHiddenSSID, eapAnonymous, eapIdentity, eapPhase2Method, background, foreground].filter(isRef),
   118 |     async () => {
   119 |       // @see https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11

Remove unnecessary type assertion in decorator.ts (typescript:S4325).
After the `node.type === 'ExportNamedDeclaration'` discriminated union
check, TypeScript already narrows `node` to `estree.ExportNamedDeclaration`,
making the explicit `(node as estree.ExportNamedDeclaration)` cast redundant.
@sonar-nigel sonar-nigel bot marked this pull request as ready for review March 19, 2026 17:03
@sonar-nigel sonar-nigel bot requested a review from a team March 19, 2026 17:03
@sonar-review-alpha
Copy link
Copy Markdown

sonar-review-alpha bot commented Mar 19, 2026

Summary

This PR eliminates false positives in rule S7763 (prefer-export-from) where locally-defined exports were incorrectly flagged as re-export candidates.

Key distinction: export const X = importedY creates a local binding X with imported value, not a re-export. The rule should only flag genuine re-export patterns like export { X } from './module' or import X from './m'; export { X }.

What changed:

  • New isFromImport() check: suppresses exports for identifiers not found in any import statement (clearly local)
  • Suppression for ExportNamedDeclaration with declaration property (the export const alias = ... pattern)
  • Reordered checks: local/non-import cases → identifier validation → default import special case
  • Validation: 4 false positives fixed across eigen and rxjs, ruling analysis confirms 73 genuine re-exports remain flagged

What reviewers should know

Start here: packages/jsts/src/rules/S7763/decorator.ts lines 47-68 show the three suppression conditions in execution order — understanding the order is key to why this works.

Core logic: The isFromImport() helper (lines 92-107) is the linchpin — it catches locally-defined exports by checking if an identifier actually appears in any import statement. If not imported, it's not a re-export candidate.

Watch for: The sentinel test at lines 23-44 of the unit file. It verifies that the upstream ESLint rule (unicorn/prefer-export-from) still raises on these patterns. If that starts failing in the future, it means upstream changed and the decorator may be obsolete — this is intentional guard-rail testing.

False positives fixed: The ruling file deletions (rxjs-S7763.json completely removed, one entry removed from eigen) represent real codebases where this pattern legitimately appears. The patterns (adapter/facade exports, namespace aliases) are documented in the test cases.


  • Generate Walkthrough
  • Generate Diagram

🗣️ Give feedback

@sonar-review-alpha
Copy link
Copy Markdown

sonar-review-alpha bot commented Mar 19, 2026

Summary

⚠️ The PR description exceeded the analysis limit and was truncated. The review may not reflect all context.

S7763 false positives have been fixed by making the decorator more precise about which export patterns can be converted to export...from syntax.

The problem: The rule was flagging locally-defined exports and default import aliases as re-export candidates, even though these patterns cannot be rewritten as export...from (estimated ~2,594 false positives).

The solution: Added explicit checks before forwarding reports from the upstream rule:

  • Function/class declarations (export function foo() {}) are always local — suppress
  • Variable aliases from default imports (import foo from './foo'; export const bar = foo) are intentional naming — suppress
  • Local variables (export const x = localValue) are never imported — suppress
  • Named and namespace import aliases (import {x}...; export const y = x) are still reported as genuine candidates that can be rewritten

The logic refactors three helper functions into a single getImportKind() utility that classifies each identifier as 'default', 'named', or null (not imported), making the suppression logic clearer and more maintainable.

What reviewers should know

Start with: The decorator logic in decorator.ts — the new conditional block for ExportNamedDeclaration (lines 50–77) is the core fix. It handles the three FP patterns differently from ExportSpecifier nodes.

Key logic:

  • For variable exports, each declarator's initializer is checked: only named/namespace imports trigger a report (lines 60–65). Default imports and local variables bypass reporting entirely.
  • For other node types (like ExportSpecifier in export { named }), the check is simpler: suppress if the identifier is null (local) or 'default' (lines 82–85).
  • The fail-open behavior (line 79–81) ensures unknown node types still report rather than silently drop detections.

Test changes: The new test cases (lines 61–71 in unit.test.ts) document the four FP patterns that are now suppressed. The sentinel test (lines 23–42) verifies the upstream rule still fires on these patterns — a helpful early warning if upstream behavior changes.

One unrelated fix: The TypeScript version validation in analyzeProject-sonarqube.test.ts was changed from hardcoding expected versions to validating format with semver.valid() — this removes test flakiness from version changes (lines 177–181).


  • Generate Walkthrough
  • Generate Diagram

🗣️ Give feedback

sonar-review-alpha[bot]

This comment was marked as resolved.

@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 19, 2026

Thanks for the summary — the description accurately captures the intent and approach of this fix.

@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 19, 2026

Thanks for the summary — the description accurately captures the three suppression conditions added to the decorator and the net effect on ruling results.

Vibe Bot added 2 commits March 19, 2026 18:45
…ules/S7763/decorator.ts

Comment: **Logic duplication:** `isFromImport` and `isDefaultImport` both walk `sourceCode.ast.body`, enter each `ImportDeclaration`, and scan specifiers by `specifier.local.name`. Since `isDefaultImport` is only ever called when `isFromImport` already confirmed the identifier exists in an import, the AST is traversed twice for that name.

A single helper avoids the double pass and keeps the detection logic in one place:

```ts
function getImportKind(
  sourceCode: SourceCode,
  identifierName: string,
): 'default' | 'named' | null {
  for (const node of sourceCode.ast.body) {
    if (node.type !== 'ImportDeclaration') continue;
    for (const specifier of node.specifiers) {
      if (specifier.local.name !== identifierName) continue;
      if (
        specifier.type === 'ImportDefaultSpecifier' ||
        (specifier.type === 'ImportSpecifier' &&
          (specifier.imported.type === 'Identifier'
            ? specifier.imported.name
            : String(specifier.imported.value)) === 'default')
      ) {
        return 'default';
      }
      return 'named';
    }
  }
  return null;
}
```

The call site becomes:
```ts
const importKind = getImportKind(context.sourceCode, identifierName);
if (!importKind || importKind === 'default') return;
context.report(reportDescriptor);
```

If traversal logic ever needs to change (e.g. handling `export … from` re-declarations), there is only one place to update.

- [ ] Mark as noise
…ules/S7763/decorator.ts

Comment: Is suppressing all `ExportNamedDeclaration` nodes with a `declaration` intentional for *every* possible declared type, or only `VariableDeclaration`?

The unicorn rule can also report on `export function foo() {}` and `export class Foo {}` where the function/class body references an imported value. Those nodes also have `declaration !== null`. Suppressing them all is conservative and avoids FPs, but it means the rule goes silent on cases it might legitimately catch — worth a comment confirming the intended scope.

- [ ] Mark as noise
@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 19, 2026

Thanks for the review! Both questions have been addressed in the latest commits:

  1. The scope of ExportNamedDeclaration suppression is now documented with a comment explaining that all declared types (VariableDeclaration, FunctionDeclaration, ClassDeclaration) are intentionally suppressed — none of them can be simplified to export…from.
  2. The logic duplication between isFromImport/isDefaultImport has been eliminated by the getImportKind helper.

sonar-review-alpha[bot]

This comment was marked as resolved.

@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 19, 2026

Thank you for the review!

@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 25, 2026

Both questions from this review have been addressed: the getImportKind refactor (single AST pass, no duplication) and the intentional suppression comment for all ExportNamedDeclaration nodes. Thanks for the thorough review!

s7763-local-exports-incorrectly-flagged-as-re-export-candidates-sonnet
@sonarqube-next
Copy link
Copy Markdown

sonar-review-alpha[bot]

This comment was marked as outdated.

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

LGTM - fixes S7763 false positives when exporting locally-defined identifiers (export const alias, export function, and export { localFn }) that cannot be collapsed to export…from — #6644

@vdiez
Copy link
Copy Markdown
Contributor

vdiez commented Mar 27, 2026

I reviewed this change with the goal of fixing JS-1475 without reducing S7763 detection coverage.

I think we should request changes before merging.

Main concern:

  • packages/analysis/src/jsts/rules/S7763/decorator.ts now suppresses every ExportNamedDeclaration with declaration !== null. This fixes the targeted FP, but it also drops detection for import-backed alias re-exports that are still genuine candidates for export ... from (e.g. import {x} ...; export const y = x; and import * as ns ...; export const N = ns;).
  • We can see the impact in ruling expectation removals (rxjs/src/observable/{concat,merge,zip}.ts and eigen/src/palette/index.tsx).

Suggested adjustment:

  1. Do not blanket-suppress all declaration exports.
  2. Handle export const alias = expr selectively:
    • if expr resolves to default import -> suppress (existing intended exception)
    • if expr resolves to named/namespace import -> keep reporting
    • if expr is local/non-import -> suppress
  3. Keep defensive behavior fail-open for unknown report node shapes (!identifierName should report, not suppress), so we do not silently lose detection on upstream changes.

Also worth considering: instead of broad decorator suppression, evaluate whether we can use unicorn?s ignoreUsedVariables option to reduce FPs while preserving detection semantics from the original rule:
https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-export-from.md

@francois-mora-sonarsource francois-mora-sonarsource removed the request for review from a team March 30, 2026 09:10
…iases in S7763

The blanket suppression of ExportNamedDeclaration with declaration !== null was
too broad: it dropped detection for `import {x}...; export const y = x` and
`import * as ns...; export const N = ns`, which ARE genuine re-export candidates
that unicorn can rewrite as `export { x as y } from '...'` and
`export * as N from '...'`.

Replace the blanket suppress with selective VariableDeclaration handling:
- FunctionDeclaration/ClassDeclaration remain fully suppressed (always local)
- VariableDeclaration: check each declarator's init via getImportKind; report
  if the RHS is a named/namespace import, suppress otherwise

Also change the !identifierName guard to fail-open (report) so unknown upstream
node shapes do not silently lose detection.

Restore ruling expectations for rxjs/src/observable/{concat,merge,zip}.ts and
eigen/src/palette/index.tsx which are genuine re-export patterns removed by the
previous over-broad suppression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Mar 30, 2026

You're right on all counts. The blanket suppression was too broad and dropped detection for genuine re-export candidates.

Changes made:

  1. Selective VariableDeclaration handling (instead of blanket suppress):

    • FunctionDeclaration / ClassDeclaration → still suppressed (always local)
    • VariableDeclaration → checks each declarator's init via getImportKind:
      • named/namespace import → report (genuine candidate)
      • default import or local → suppress
  2. Fail-open for !identifierName → now reports instead of suppresses

  3. Ruling files restored: rxjs/src/observable/{concat,merge,zip}.ts and eigen/src/palette/index.tsx are back in the ruling expectations

  4. Tests updated: export const alias = namedImport and export const N = ns moved from valid to invalid; sentinel updated to test the default import re-export pattern (which the decorator still suppresses)

Regarding ignoreUsedVariables: the selective AST check achieves the same precision without needing a config change, and it keeps the fix in the decorator where the other suppression logic lives.

The upstream unicorn/prefer-export-from rule changed its autofix output
for default import re-exports: it now generates
`export {default as foo} from './foo'` instead of `export {foo} from './foo'`.

Update the sentinel test's expected output to match the new upstream behavior.
The sentinel test purpose (verifying the upstream rule still raises on these
patterns) is unchanged — it still expects errors: 1.
sonar-review-alpha[bot]

This comment was marked as outdated.

…ing nightly version

The test was asserting an exact nightly TypeScript version string
('7.0.0-dev.20260316.1') which changes with each nightly build. Use the
same approach as master: check that versions are non-empty and valid
semver strings rather than matching a specific nightly tag.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

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

LGTM! ✅

🗣️ Give feedback

Copy link
Copy Markdown
Contributor

@vdiez vdiez left a comment

Choose a reason for hiding this comment

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

Looks good.

I reviewed the S7763 decorator changes with focus on FN risk. The updated import-kind handling keeps reporting named/namespace re-export candidates while suppressing the intended local/default-import FP patterns, and the added coverage plus sentinel test are solid.

Approved.

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