Skip to content

Namespace and star re-export crediting gaps around the #2372/#2373 closure #2391

Description

@BartWaardenburg

Adversarial review of the #2355 through #2377 fix sweep surfaced these as pre-existing defects: each reproduces on a binary built from before that sweep, so none is a regression from it. They are grouped by area rather than filed one by one; split off whichever ones get picked up.

Verify each item against current main before starting. The sweep landed eleven fixes in these same areas, so some items may already be closed or may have shifted. Every item is independent and can land on its own.

Definition of done, per item

  • Fix the earliest layer that is wrong (extraction before graph before analysis before reporting), not the surface that shows the symptom.
  • Add a fixture under crates/core/tests/fixtures/ registered in crates/core/tests/integration_test.rs, plus unit tests. Every new test must fail with the source change reverted: state that check in the PR.
  • Any change to persisted extract or graph output bumps the matching constant (CACHE_VERSION in crates/extract/src/cache/types.rs, GRAPH_CACHE_VERSION in crates/graph/src/cache/mod.rs) with a doc comment naming the issue, and mirrors any new field through the cache structs. A field that the mirror drops silently replays the old behaviour behind a warm cache: prove the round trip with a test and run the old binary, then the new one, over the same .fallow directory.
  • Any JsonSchema type or rustdoc change regenerates the contracts (npm run generate:contracts) so docs/output-schema.json and both generated TypeScript contract files move in lockstep.
  • Add a CHANGELOG entry under ## [Unreleased] naming the finding-count movement in both directions.
  • Compare the old and new binary on a real project and explain every changed finding in the PR.
  • Run the gates in docs/development/quality-gates.md.

Findings (21)

1. Whole-object use of an export * as binding imported by name credits the namespace target's direct exports only

BODY: A consumer that imports an export * as sub binding by name and observes it as a whole object (Object.values(sub)) is credited by the namespace re-export phase (Phase 2c) with every direct export of sub.ts, but sub.ts's own export * and export * as sub2 chains are not followed, so the star-forwarded names and the nested namespace are reported as unused. This is the sibling of #2372 for the Phase 2c consumer path: the whole-object credit is discovered inside the Phase 2c loop after the exposed namespace closure has been computed, so extending it needs a fixpoint or a pre-pass there. Unlike the entry-point named re-export shape closed by #2373, the consumer here imports the binding, so no entry surface carries the name.

Repro (fallow dead-code --format json --quiet --no-cache):

package.json

{ "name": "repro", "type": "module", "main": "src/index.ts" }

src/index.ts

import { sub } from './barrel';

export const all = Object.values(sub);

src/barrel.ts

export * as sub from './sub';

src/sub.ts

export * from './deep';
export * as sub2 from './sub2';
export const subOne = 1;

src/deep.ts

export const deepOne = 1;
export default function deepDefault(): void {}

src/sub2.ts

export const sub2One = 1;
export default function sub2Default(): void {}

Reported: src/deep.ts:deepOne, src/deep.ts:default, src/sub2.ts:sub2One, src/sub2.ts:default. Expected: only src/deep.ts:default (a plain export * never forwards default; sub.sub2 is sub2's namespace object, so sub2One and sub2's default are reachable; deepOne arrives through sub's export *). subOne is correctly credited. Reproduces identically on a binary built from main e65a908 and on PR #2379's head, whose closure is seeded from Phase 2 namespace edges and from the entry-point export surface but not from Phase 2c consumer credits.

2. export type * inside a declare module body still creates a file-level type-only star re-export

BODY: #2357 routes export * and export * as ns inside an ambient body through a whole-module import so the declaring file gains no surface, but export type * from './impl' (and export type * as ns) keeps the pre-existing file-level type-only star re-export: the whole-module import shape carries no type modifier, and routing the type-only star through it would credit the value meaning of every target export.

Observed (re-verified on the rebased #2357 branch binary at d186f2f and on a binary built from main c3df885, identical): with src/pair.ts: export interface Foo { a: number } export const Foo = 1; export type Bar = string; export const plain = 3; export default function d(): void {}, a non-entry src/shim.ts carrying declare module 'pkg' { export type * from './pair' } export {}; (imported for side effects by the entry) reports pair.ts:Foo (interface and const), Bar, plain, and default; the interface half of Foo and the Bar alias are false positives because the type star forwards them. With the declaration in an entry-point .d.ts instead, the file-level star launders the consts into the entry surface and only default reports, a false negative; export type * as ns additionally reports shim.ts:ns as an unused type on the declaring file.

Possible direction: give the extractor a way to record a type-only whole-module import (a flag on ImportInfo, which needs an extract cache bump and constructor updates) so the graph can mark the star surface in the type lane only, skipping defaults and crediting interface Foo without const Foo. Rare form; the extract test ambient_module_type_only_star_re_export_keeps_file_level_shape pins the current shape so the change is deliberate when it lands.

3. An entry point that hands its namespace import out through anything other than a bare named re-export credits nothing

BODY: narrow_namespace_references suppresses the mark-all for a namespace import on an entry point whenever there is no member access, no whole-object use, and no export whose local name is the binding (is_entry_with_no_access). PR #2379 lifted that suppression for export { ns } and export { ns as anything }, which is the shape #2373 named. The sibling shapes are still suppressed, and on an entry point they are all public API: everything on the namespace object is reachable by a consumer of the package, yet every export of the barrel is reported as unused.

Repro (fallow dead-code --format json --quiet --no-cache). Three variants share these files:

package.json

{ "name": "repro", "type": "module", "main": "src/index.ts" }

src/barrel.ts

export * from './deep';
export * as sub from './sub';
export const barrelOne = 1;

src/deep.ts

export const deepOne = 1;
export default function deepDefault(): void {}

src/sub.ts

export const subOne = 1;
export default function subDefault(): void {}

Variant A, src/index.ts

import * as ns from './barrel';

export default ns;

Variant B, src/index.ts

import * as ns from './barrel';

export const api = ns;

Variant C, src/index.ts

import * as ns from './barrel';
import { consume } from './consumer';

export const app = consume(ns);

with src/consumer.ts

export const consume = (value: unknown): unknown => value;

All three report: src/barrel.ts:barrelOne, src/barrel.ts:sub, src/deep.ts:deepOne, src/deep.ts:default, src/sub.ts:subOne, src/sub.ts:default. Expected for A and B: nothing (the namespace object is the package's default export, respectively a named export, so every name on it is reachable). Expected for C: at minimum the barrel's direct exports, matching what the same file credits when it is not an entry point.

The control that proves the guard is the cause: move each body into a non-entry src/m.ts and have the entry import from it. The non-entry variants of all three credit the full chain on #2379 (src/deep.ts:default alone remains, correctly). Also note export { ns as default } on the entry does credit the

4. export = ns does not credit the target's exports, unlike export { ns }

BODY: A namespace binding re-exported with export = hands the whole module object to consumers, but fallow narrows it to the members the declaring file happens to read, so every export of the target reports as unused. export { ns } credits the whole object correctly (issue #2373), export = ns and export default ns do not.

Reproduction, reproduces identically on the current release and on main:

package.json:

  { "name": "p", "main": "src/index.ts" }

src/target.ts:

  export const a = 1;
  export const b = 2;

src/index.ts:

  import * as Inner from './target';
  export = Inner;

fallow dead-code --format json --quiet --no-cache reports a and b as unused exports, even though the entry point re-exports the whole module object under export =. Every consumer of the package can read both names.

Control: replacing the last line with export { Inner }; reports nothing, which is the correct result. export default Inner; reproduces the same two false rows as export =.

Where: is_re_exported in crates/graph/src/graph/narrowing.rs matches only a top-level export whose local_name equals the binding. A TSExportAssignment (and a default-export of a bare identifier) records no such local_name, so the whole-object credit never fires.

This matters more now that import X = require('./x') records an edge: import Inner = require('./target'); export = Inner; is the canonical CommonJS barrel, and it lands directly on this gap.

5. A namespace binding that is both an object-literal alias source and re-exported by name loses its export * chain credit

BODY: The exposed-namespace closure deliberately skips a namespace binding that the namespace-object alias phase can narrow (export const API = { ns }), because that phase follows API.ns.<member> precisely. The exclusion is decided from how the defining module uses the binding, so it also fires when the same binding is re-exported by name and a downstream consumer observes it as a whole object. In that combination nothing follows the chain, and the star-forwarded names stay reported.

Reproduction

Run fallow dead-code --format json --quiet --no-cache.

package.json

{"name":"probe","type":"module","main":"src/index.ts"}

src/index.ts

import { API, ns } from './api';

export const app = API.ns.direct;
export const raw = Object.keys(ns).length;

src/api.ts

import * as ns from './barrel';

export const API = { ns };
export { ns };

src/barrel.ts

export * from './deep';

export const direct = 1;

src/deep.ts

export const deepOne = 1;

Reported: src/deep.ts:deepOne.
Expected: nothing. Object.keys(ns) in the entry observes the whole namespace object of barrel.ts, and deepOne is on that object through barrel's export *.

Control

Replace export { ns }; in src/api.ts with a whole-object use inside the same module:

import * as ns from './barrel';

export const API = { ns };
export const size = Object.keys(ns).length;

and src/index.ts with

import { API, size } from './api';

export const app = API.ns.direct + size;

With that change nothing is reported: the whole-object use in the defining module lifts the alias exclusion and the chain is credited.

Cause

narrow_namespace_references (crates/graph/src/graph/narrowing.rs) gates the closure seed on observes_whole_module && (is_whole_object || !is_alias_source). Both is_whole_object and is_alias_source are evaluated against the defining module, so a downstream whole-object use of the re-exported binding can never lift the exclusion, even though the alias phase cannot narrow accesses it does not see. Lifting the exclusion when is_re_exported_from_non_entry is also true would close it, since a re-exported bindi

6. An export whose credit is lost to a star-export collision traces as plainly unused, and the barrel name yields an error instead of a trace

BODY: export_trace_reason does not consult graph.ambiguity_participants(), the set find_unused_exports deliberately skips (issue #2263), so a declaration whose name collides across two star re-exports traces with the generic unused wording even though dead-code intentionally does not report it.

Repro:

package.json

{"name":"probe","type":"module","main":"src/index.ts"}

src/a.ts

export const helper = 1;

src/b.ts

export const helper = 2;

src/barrel.ts

export * from './a';
export * from './b';

src/index.ts

import type { helper } from './barrel';
export type T = typeof helper;

fallow dead-code --format json --quiet --no-cache reports total_issues 1 with unused_exports: [] (a duplicate-export finding instead).

fallow dead-code --trace src/a.ts:helper --format json --quiet --no-cache reports "namespace": "value", "is_used": false, "reason": "No references found, export is unused", which reads as a deletion invitation for a declaration dead-code deliberately refuses to flag.

fallow dead-code --trace src/barrel.ts:helper --format json --quiet --no-cache returns {"error":true,"message":"export or member 'helper' not found in 'src/barrel.ts'","exit_code":2} instead of any trace payload, so the barrel address gives the user nothing either.

Suggested fix: have export_trace_reason consult graph.ambiguity_participants() and emit a collision-specific reason, and consider making the barrel address return a collision trace instead of a not-found error.

7. A require-bound namespace credits only the value namespace, so type exports reached through X.SomeType are reported unused

BODY: const X = require('./m') (and, since #2365, import X = require('./m')) resolves to ImportedName::Namespace, but the binding is never classified for type usage, so only the value namespace is credited. A type export reached through X.SomeType in a type position is reported as an unused type even though it is used. The equivalent import * as X from './m' credits it correctly.

Reproduction

package.json with "main": "src/index.ts":

{ "name": "repro", "version": "1.0.0", "main": "src/index.ts" }
// src/t.ts
export interface A { a: number }
export type B = string;
export interface C { c: number }
// src/index.ts (entry)
const T = require('./t');
export const use = (v: T.A): T.B => String(v.a);

fallow dead-code --format json --quiet --no-cache reports A, B and C as unused types. A and B are used.

Control: replace the consumer line with import * as T from './t'; and only C is reported, which is correct.

fallow dead-code --trace src/t.ts:A --no-cache prints File: reachable / Namespace: type / Reason: No references found, export is unused.

Where

compute_semantic_usage_with_candidates in crates/extract/src/parse.rs builds type_referenced_import_bindings and value_referenced_import_bindings by iterating imports only, so a require-derived local never appears in either list. desired_import_namespaces in crates/graph/src/graph/narrowing.rs therefore falls through to { uses_type: false, uses_value: true, classified: false }, and narrow_namespace_references skips the ExportNamespace::Type loop, so mark_member_exports_referenced_at_site never runs for type exports.

Expected

A require-bound namespace binding is classified for type and value usage the same way an ESM namespace import is, so X.SomeType in a type position credits the target's type export.

Note

The same defect surfaces through the TypeScript spelling import X = require('./m'), where type-space qualified references are the dominant real-world use (the canonical import express = require('express') shape, and most .d.ts uses). Fixing the classification once covers both spellings.

8. A namespace handed over whole credits every export but does not abstain the class-member and enum-member detectors

BODY: ## Summary

When a consumer hands an import * as NS object over whole, the graph credits every export of the target (mark-all), but the member-level detectors keep reporting on those exports' members. The receiver holds the whole namespace object and can construct any exported class and call any method on it, so unused-class-member and unused-enum-member rows on the target are false positives in that situation.

This is pre-existing and independent of #2377: it reproduces identically for the long-standing Object.values(NS) whole-object shape and for the bare-pass shape added by #2377.

Reproduction

package.json

{ "name": "ns-member-abstain", "main": "src/index.ts" }

src/keys-icons.ts

export class KeysWidget {
  used(): number { return 1; }
  maybe(): number { return 2; }
}
export enum KeysMode { First = 'first', Second = 'second' }
export const KeysStar = (): null => null;

src/keys.ts

import * as KeysIcons from './keys-icons';
const hand = (x: unknown): unknown => x;
export const useKeys = (): unknown => {
  KeysIcons.KeysStar();
  return hand(Object.values(KeysIcons));
};

src/index.ts

export { useKeys } from './keys';

Run fallow dead-code --no-cache --format json --quiet.

Actual

KeysWidget and KeysMode are correctly credited (no unused-export rows), but the report still carries:

  • unused_class_members: src/keys-icons.ts KeysWidget.used and KeysWidget.maybe (kind: class_method)
  • unused_enum_members: src/keys-icons.ts KeysMode.First and KeysMode.Second (kind: enum_member)

The same rows appear for the bare-pass spelling (return hand(KeysIcons); instead of the Object.values wrapper).

Expected

A whole-object use of a namespace-import local should abstain the member detectors for the target's exports, the way whole_object_used_exports already abstains them for named and default imports.

Cause

build_local_to_export_keys in crates/core/src/analyze/members/mod.rs maps a local name to export keys only for ImportedName::Named and ImportedName::Default; imported_export_name returns None for ImportedName::Namespace. The whole-object loop th

9. Entry-point export { sub } from './barrel' credits an export * as sub target's direct exports but not its own export * chain

BODY: #2373 fixed the case where an entry point reaches an export * as sub barrel through a plain export *. The sibling case, where the entry names the sub binding through a named re-export, still stops at sub's direct exports: the names sub only exposes through its own export * from './deep' stay reported.

Reproduction

Run fallow dead-code --format json --quiet --no-cache.

package.json

{"name":"probe","type":"module","main":"src/index.ts"}

src/index.ts

export { sub } from './barrel';

src/barrel.ts

export * as sub from './sub';

export const barrelOne = 1;

src/sub.ts

export * from './deep';

export const subOne = 1;
export default function subDefault(): void {}

src/deep.ts

export const deepOne = 1;
export default function deepDefault(): void {}

Reported: src/barrel.ts:barrelOne, src/deep.ts:deepOne, src/deep.ts:default.
Expected: src/barrel.ts:barrelOne (not re-exported, correct) and src/deep.ts:default (a plain export * never forwards default, correct). src/deep.ts:deepOne is a false positive: sub.deepOne is on the namespace object the entry exports. subOne and sub.ts:default are already credited correctly.

Control

Change only src/index.ts to

export * from './barrel';

and the same tree reports only src/deep.ts:default after #2372/#2373. The single difference between the two projects is whether the entry reaches the barrel through a plain export * or through a named re-export.

Cause

ModuleGraph::collect_exposed_namespace_targets (crates/graph/src/graph/re_exports/mod.rs) builds its entry surface from entry points closed over plain export * chains only, then seeds the export * as sources found on that surface. A barrel reached by a named re-export is never on the surface, so sub is never a closure member and its own export * chain is never followed. Phase 2c still credits sub's direct exports because the entry point itself satisfies exposes_namespace_object.

Reproduces identically on main e65a9083e and on PR #2379 head 1a81fc18e, so this is pre-existing. It is the export-side sibling of the Phase 2c consumer gap already

10. An entry point that exposes an export * as namespace through a named re-export still stops at the first namespace level

BODY: Sibling of #2373 that #2379 does not close. ModuleGraph::collect_exposed_namespace_targets seeds the entry-surface half of the closure from the export * as sources of entry points and of barrels an entry reaches through plain export *. A barrel the entry reaches through a named re-export is not on that surface, so its namespace source never joins the closure, even though Phase 2c still credits the source's direct exports because the entry point itself appears in the reachable-barrel set. That is exactly the split #2373 describes.

Repro (fallow dead-code --format json --quiet --no-cache):

package.json

{ "name": "repro", "type": "module", "main": "src/index.ts" }

src/index.ts

export { sub } from './barrel';

src/barrel.ts

export * as sub from './sub';

src/sub.ts

export * from './deep';
export * as sub2 from './sub2';
export const subOne = 1;
export default function subDefault(): void {}

src/deep.ts

export const deepOne = 1;
export default function deepDefault(): void {}

src/sub2.ts

export const sub2One = 1;
export default function sub2Default(): void {}

Reported: src/deep.ts:deepOne, src/deep.ts:default, src/sub2.ts:sub2One, src/sub2.ts:default.
Expected: only src/deep.ts:default (a plain export * never forwards default; sub.deepOne arrives through sub's export *, and sub.sub2 is sub2's namespace object so both of its exports are reachable). sub.ts:subOne and sub.ts:default are already credited correctly on both sides, which is the tell that only the chain half is missing.

Reproduces identically with a binary built from main e65a908 and with the #2379 head, so it is pre-existing. Root cause is shared with the import { sub } from './barrel'; Object.values(sub) sibling: the exposed-namespace closure is computed once before Phase 2c and Phase 2c's own exposes_namespace_object verdicts are never fed back into it. A fixpoint or pre-pass over the reachable-barrel sets would close both in one rule.

11. export * as behind an ambiguous duplicate-star name credits the namespace target's exports

BODY: When two export * sources of one module both export the same name, ES excludes that name from the module's exports entirely (it is ambiguous). Fallow still credits the export * as namespace target behind such a name, so genuinely unreachable exports are not reported.

Repro (fallow dead-code --format json --quiet --no-cache):

package.json

{ "name": "repro", "type": "module", "main": "src/index.ts" }

src/index.ts

export * from './a';
export * from './b';

src/a.ts

export * as dup from './target-a';

src/b.ts

export const dup = 1;

src/target-a.ts

export * from './target-a-deep';
export const targetAOne = 1;

src/target-a-deep.ts

export const targetADeepOne = 1;

Expected: src/a.ts:dup, src/target-a.ts:targetAOne and src/target-a-deep.ts:targetADeepOne are all unreachable (index.dup is excluded because two stars export it), so all three should report.

A binary built from main e65a908 reports only src/target-a-deep.ts:targetADeepOne, so the direct-export half (targetAOne) is already mis-credited before any recent change: the namespace re-export phase (Phase 2c, enumerate_reachable_barrels plus uniquely_forwards_binding) treats the barrel as reaching the entry point even though the name is ambiguous there. Phase 2c does handle the sibling shadowing case correctly (a local export of the same name on the forwarding barrel keeps the target reported), so the ambiguity rule is the missing half of the same check.

After the #2372/#2373 exposed-namespace closure lands, targetADeepOne is credited too, so this shape reports nothing at all.

12. A whole-object namespace use inside an unused file credits a live barrel's direct exports

BODY: A namespace import used as a whole object marks all of the target's exports referenced even when the consuming file is not reachable from any entry point, so an orphan file that nothing imports hides genuine unused exports on a live module. The report calls the consumer an unused file in the same run.

Repro (fallow dead-code --format json --quiet --no-cache):

package.json

{ "name": "repro", "type": "module", "main": "src/index.ts" }

src/index.ts

import { barrelOne } from './barrel';

export const app = (): number => barrelOne;

src/barrel.ts

export * from './deep';
export const barrelOne = 1;
export const barrelTwo = 2;

src/deep.ts

export const deepOne = 1;

src/orphan.ts

// Nothing imports this file.
import * as ns from './barrel';

export const total = Object.values(ns).length;

Reported: src/orphan.ts as an unused file, and src/deep.ts:deepOne. Not reported: src/barrel.ts:barrelTwo, even though the only thing that observes it is the orphan file the same report calls unused. Expected: barrelTwo reported as well, since deleting orphan.ts (as the report advises) leaves it with no consumer.

Reproduces on the binary built from main e65a908, so this predates #2372/#2373. The mark-all site in narrow_namespace_references does not consult entry-point reachability for site.from_file. Note that PR #2379 adds an entry-reachability gate for the target of the exposed namespace closure but not for the consumer, which extends the same blind spot to the barrel's whole export * and export * as chain (deepOne is credited there too).

13. MCP project_info can report a stale analysis-stage workspace diagnostic until the next dead-code analyze pass in the same server process

BODY: Analysis-stage diagnostics (malformed-pnpm-workspace-yaml, bun-lockb-override-resolution-skipped) are refreshed only by the dead-code analyze pass, which clears them at its start and re-records what still applies. Surfaces that read the diagnostics registry without running that pass (the MCP project_info tool, and the list/workspaces inventory path behind it) therefore keep reporting the last recorded state until some analyze tool runs again.

Repro (no local paths needed), driving the real MCP server over stdio and editing files between requests:

  1. Create a project directory with these three files.

package.json:

{
  "name": "repro-project-info-stale",
  "private": true,
  "overrides": {
    "ws": "^8.21.0"
  }
}

bun.lockb (any non-empty content):

placeholder binary lockfile

src/index.ts:

export const value = 1;
  1. Start fallow-mcp, send initialize and the notifications/initialized notification, then over the SAME stdio connection:
    • call tool analyze with {"root": "<project>", "no_cache": true} -> workspace_diagnostics = ["bun-lockb-override-resolution-skipped"]
    • call tool project_info with {"root": "<project>"} -> ["bun-lockb-override-resolution-skipped"] (correct, the cause is still there)
    • now write a parseable text lockfile next to the manifest, bun.lock:
      {"lockfileVersion":1,"workspaces":{"":{"name":"repro-project-info-stale"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}
    • call tool project_info again with {"root": "<project>"}

Observed: the third project_info still returns ["bun-lockb-override-resolution-skipped"] even though the cause is fixed. A following analyze correctly returns [], and a project_info after that also returns [].

Expected: either project_info refreshes analysis-stage entries itself, or it omits kinds whose owning stage did not run in this process, so it never asserts a problem that no longer exists.

Note: on a build with the #2366 registry changes reverted, the stale entry never clears at all (analyze and every later project_info keep returning it), so #2366 strictly narrows this window rath

14. A whole-object namespace use in a file no entry point reaches still credits the target, unlike a re-export reference from an unreachable barrel

BODY: The unused-export detector discounts a re-export reference whose source file no entry point reaches, but it does not discount the mark-all reference a whole-object namespace use writes from the same kind of file. One unreachable file can therefore silence exports on files the same report calls reachable, with no row anywhere pointing at the cause except the unused-file row for the observer.

Reproduces identically on the pre-change binary and on the current build, so this is pre-existing. It matters more now, because the whole-module closure extends the same credit from the target's direct exports to its entire export * and export * as chain.

Project:

package.json

{ "name": "pa", "type": "module", "main": "src/index.ts" }

src/index.ts

import { bOne } from './b';

export const app = bOne;

src/b.ts

export * from './c';

export const bOne = 1;
export const bTwo = 2;

src/c.ts

export const cOne = 1;

src/u.ts, which nothing imports

import * as ns from './b';

export const all = Object.values(ns);
fallow dead-code --format json --quiet --no-cache

unused_files contains src/u.ts. unused_exports does not contain src/b.ts:bTwo on any version, and on the current build it does not contain src/c.ts:cOne either.

Expected: src/b.ts:bTwo and src/c.ts:cOne are both reported. The only thing that observes b's namespace object is a file the report itself calls unused, so the credit rests entirely on dead code. Compare a re-export reference in the same position: an export re-exported only from an unreachable barrel is correctly still reported, so the two reference kinds disagree about what an unreachable source means.

Suggested shape: apply the same reachability discount to a mark-all reference that the detector already applies to a re-export reference, or record the observing file with the whole-object observation so the exposed namespace closure can require at least one entry-reachable observer. The second half also closes the asymmetry noted in the #2372 review, where the closure's seed gate tests the target's reachability while the justification in the docs is about the observer's.

15. A re-exported namespace binding leaves the target's type exports uncredited

BODY: When a namespace binding is re-exported and the declaring file never references it in type position, the whole-object credit widens only within the namespaces desired_import_namespaces selects (crates/graph/src/graph/narrowing.rs). The binding is value-only, so mark_all_exports_referenced_at_site never runs for the type namespace and a type export on the target is reported unused even though a consumer holding the re-exported module object can reach it.

Repro, three files:

package.json:

{
  "name": "reexport-type-repro",
  "main": "src/index.ts"
}

src/re-t.ts:

export interface ReqType {
  n: number;
}

export const reqValue = 1;

src/index.ts:

import * as Re from './re-t';

export { Re };

fallow dead-code --format json --no-cache reports unused-type ReqType on src/re-t.ts, while reqValue on the same file is correctly credited. The asymmetry is the bug: both names sit on the same re-exported module object. Replacing the import line with export import Re = require('./re-t'); reproduces identically.

Surfaced while landing #2365: the import-equals spelling now reaches the same lane, so the row is new relative to that issue's baseline, where the whole file was reported unused instead. The same root cause also produces a false unused-type for a binding declared inside a declare module '...' body, where the semantic classifier only inspects the root scope.

16. A binding declared inside a declare module '...' body is never classified into type space, so a type reached through it is reported unused

BODY: compute_semantic_usage_with_candidates resolves import bindings with scoping.get_binding(root_scope, name). A binding declared inside an ambient module body does not live in the root scope, so the lookup misses, no type or value classification is recorded, and desired_import_namespaces falls through to the value-only default. A type reached through the binding is then left uncredited.

Repro, package.json with "main": "src/index.ts":

// src/bar-esm.ts
export interface EsmThing { n: number }
export const esmThingValue = 1;

// src/ambient.d.ts
declare module 'virtual-b' {
  import * as BarEsm from './bar-esm';
  export const b: BarEsm.EsmThing;
}

// src/index.ts (entry)
export const go = (): number => 1;

fallow dead-code --format json --no-cache --quiet reports EsmThing on src/bar-esm.ts as an unused type, even though export const b: BarEsm.EsmThing references it.

Reproduces identically on the pre-#2365 binary. The import-equals spelling of the same shape (import Bar = require('./bar') in the ambient body) now hits the same path after #2365, so the gap is worth closing once for both.

Likely fix: walk every scope that can hold an import binding rather than only the root scope, or resolve the binding by symbol rather than by root-scope name lookup.

17. An unreferenced const X = require('./x') credits the whole target instead of reporting anything

BODY: An ESM namespace import whose binding is never referenced is recorded in unused_import_bindings, is_unused_import_binding short-circuits attach_symbol_reference, and the target's exports are correctly reported as unused. The require path records no unused binding, so the reference attaches anyway, the target is credited whole, and nothing is reported at all.

Repro, package.json with "main": "src/index.ts":

// src/t-req.ts
export const a1 = 1;
export const a2 = 2;

// src/t-esm.ts
export const c1 = 1;
export const c2 = 2;

// src/mid.ts
const UnusedReq = require('./t-req');
import * as UnusedEsm from './t-esm';

export const midValue = 1;

// src/index.ts (entry)
import { midValue } from './mid';
console.log(midValue);

fallow dead-code --format json --no-cache --quiet reports c1 and c2 on src/t-esm.ts and nothing at all on src/t-req.ts. Both bindings are equally dead.

Reproduces identically on the pre-#2365 binary for the const form. #2365 extends the same leniency to import X = require('./x') by design (matching the require path was the spec), which is why an unreferenced import-equals now reports nothing where the baseline reported an unused-file row for the target.

Likely fix: record an unreferenced non-destructured require binding as an unused import binding, keeping the export import form excluded since it legitimately has no local reference.

18. export = <binding> is not recorded as a re-export, so the re-exported module's exports report as unused

BODY: is_re_exported has no arm for TSExportAssignment, so a file that imports a module namespace and hands it on with export = X credits nothing. Every export of the inner module is reported as unused even though a consumer reaches it through the re-exporting file.

Reproduction

package.json:

{
  "name": "export-assignment-reexport",
  "version": "1.0.0",
  "main": "src/index.ts"
}

src/inner.ts:

export const alpha = 1;
export const beta = 2;

src/index.ts (entry):

import * as Inner from './inner';
export = Inner;

fallow dead-code --format json --quiet --no-cache reports src/inner.ts alpha and src/inner.ts beta as unused exports. Both are reachable: a consumer of the package writes require('pkg').alpha.

The same happens through a .d.ts hop:

src/m.ts:

export const alpha = 1;
export const beta = 2;

src/api.d.ts:

import M = require('./m');
export = M;

src/index.ts:

import Api = require('./api');
console.log(Api.alpha);

reports alpha and beta on src/m.ts, even though Api.alpha is read.

Expected

export = X, where X is an import binding, should credit the target the way export { X } credits a namespace import: the whole module object leaves the file, so consumers the graph cannot enumerate may read any name on it.

Where

is_re_exported has no TSExportAssignment arm. Surfaced while reviewing the fix for #2365; it is independent of that change and reproduces identically on the pre-#2365 binary and for the plain import * as X spelling.

19. A dynamic-import or require namespace object handed whole is not a whole-object use

BODY: #2377 made a reference to an import * as NS local a whole-object use whenever the visitor cannot resolve it to one member. The rule was deliberately scoped to static namespace imports, registered by a prescan of the program's statement list. Namespace objects bound by await import(...), import(...).then(m => ...), or const m = require('...') reach the same narrow_namespace_references narrowing path but keep the older allow-list (Object.keys(m), spread, for ... in, computed non-string access, rest destructure), so handing one over whole still narrows to the dotted accesses.

Reproduction

package.json:

{
  "name": "repro-dynamic-namespace",
  "main": "src/index.ts"
}

src/icons.ts:

export const Star = (): null => null;
export const Moon = (): null => null;

src/index.ts:

const register = (all: Record<string, unknown>): number => Object.keys(all).length;

export const run = async (): Promise<number> => {
  const icons = await import('./icons');
  icons.Star();
  return register(icons);
};

fallow dead-code --no-cache --format json reports src/icons.ts:Moon. It is reachable: register receives the whole namespace object.

The same holds for const icons = require('./icons').

Notes

A fix has to work differently from the static-import prescan, because these bindings are declared by ordinary statements rather than hoisted declarations, so walk order matters: a reference above the declaration (inside a function called later) would need the resolution deferred to the extractor's finalize phase instead of answered at the reference. Same cache bumps and fixture requirements as #2377.

20. Namespace passed whole in a Vue SFC template binding is not a whole-object use

BODY: A .vue single-file component that hands an import * as NS local to a child through a template binding still narrows the namespace to its dotted accesses, so every sibling export the child can reach is reported unused. The .ts / .tsx equivalent of this shape was fixed by #2377; the Vue template path is not covered, because the template expression scan parses each binding as a wrapped standalone program that carries no import declaration, so the visitor's namespace-local registry is empty when the binding is walked.

Reproduction

package.json

{ "name": "repro", "version": "1.0.0", "main": "src/index.ts" }

src/icons.ts

export const Star = (): number => 1;
export const Moon = (): number => 2;
export const Sun = (): number => 3;

src/Widget.vue

<script setup lang="ts">
import * as VueIcons from './icons';
import Panel from './PanelVue.vue';
</script>

<template>
  <div>
    <component :is="VueIcons.Star" />
    <Panel :icons="VueIcons" />
  </div>
</template>

src/PanelVue.vue

<script setup lang="ts">
defineProps<{ icons: unknown }>();
</script>

<template>
  <div>{{ icons }}</div>
</template>

src/index.ts

export { default as Widget } from './Widget.vue';

fallow dead-code --no-cache --format json reports src/icons.ts:Moon and src/icons.ts:Sun. Both are reachable: :icons="VueIcons" hands the whole namespace object to Panel, which can read any export off the prop.

Expected: no finding, the same guarantee .tsx gets since #2377 and Astro/MDX get from their whole-file completeness guard.

Verified identical on the #2377 branch and on its base commit, so this is pre-existing and out of that fix's scope.

Surfaced while reviewing the fix for #2377.

21. A require-bound or dynamically imported namespace object passed whole is not a whole-object use

BODY: #2377 made a bare reference to an import * as NS local a whole-object use. Namespace objects bound by require() or by await import() were deliberately left on the older allow-list, so the same false positive survives for those two binding forms: a consumer that hands the object over whole while also reading one member still narrows to that member, and the siblings the receiver can reach are reported unused.

Reproduction

package.json

{ "name": "repro", "version": "1.0.0", "main": "src/index.ts" }

src/req-icons.ts and src/dyn-icons.ts (identical content)

export const Star = (): number => 1;
export const Moon = (): number => 2;

src/req.ts

const consume = (v: unknown): unknown => v;
const Req = require('./req-icons');

export const useReq = (): unknown => consume(Req) ?? Req.Star();

src/dyn.ts

const consume = (v: unknown): unknown => v;

export const useDyn = async (): Promise<unknown> => {
  const Dyn = await import('./dyn-icons');
  return consume(Dyn) ?? Dyn.Star();
};

src/index.ts

export { useReq } from './req';
export { useDyn } from './dyn';

fallow dead-code --no-cache --format json reports src/req-icons.ts:Moon and src/dyn-icons.ts:Moon. Both are reachable through consume(...).

Expected: no finding, matching the import * as behaviour.

Note for whoever picks this up: unlike static imports, these bindings are not hoisted statements, so a statement-list prescan is not the right registration point; the existing walk-order registry is. Verified identical on the #2377 branch and on its base commit.

Surfaced while reviewing the fix for #2377.

Activity

  1. added
    bugSomething isn't working
    rustPull requests that update rust code
    area:graphModule graph and dependency resolution
    on Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:graphModule graph and dependency resolutionbugSomething isn't workingrustPull requests that update rust code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions