You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Namespace and star re-export crediting gaps around the #2372/#2373 closure #2391
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.
ES semantics are the reference: a plain export * never forwards default; a namespace object (export * as ns, a whole-object use, a dynamic-import target) does.
Crediting changes belong in extraction or the graph closure, not in the reporting layer.
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.
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:
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.
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:
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.
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.
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.
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.tsKeysWidget.used and KeysWidget.maybe (kind: class_method)
unused_enum_members: src/keys-icons.tsKeysMode.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.
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.
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.
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.
// Nothing imports this file.import*asnsfrom'./barrel';exportconsttotal=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:
Create a project directory with these three files.
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.
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.
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.
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.
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.
fallow dead-code --format json --quiet --no-cache reports src/inner.tsalpha and src/inner.tsbeta as unused exports. Both are reachable: a consumer of the package writes require('pkg').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.
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.
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.
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.
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.
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
mainbefore 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.export *never forwardsdefault; a namespace object (export * as ns, a whole-object use, a dynamic-import target) does.Definition of done, per item
crates/core/tests/fixtures/registered incrates/core/tests/integration_test.rs, plus unit tests. Every new test must fail with the source change reverted: state that check in the PR.CACHE_VERSIONincrates/extract/src/cache/types.rs,GRAPH_CACHE_VERSIONincrates/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.fallowdirectory.JsonSchematype or rustdoc change regenerates the contracts (npm run generate:contracts) sodocs/output-schema.jsonand both generated TypeScript contract files move in lockstep.## [Unreleased]naming the finding-count movement in both directions.docs/development/quality-gates.md.Findings (21)
1. Whole-object use of an
export * asbinding imported by name credits the namespace target's direct exports onlyBODY: A consumer that imports an
export * as subbinding 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 ofsub.ts, butsub.ts's ownexport *andexport * as sub2chains 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.tssrc/barrel.tssrc/sub.tssrc/deep.tssrc/sub2.tsReported:
src/deep.ts:deepOne,src/deep.ts:default,src/sub2.ts:sub2One,src/sub2.ts:default. Expected: onlysrc/deep.ts:default(a plainexport *never forwardsdefault;sub.sub2is sub2's namespace object, sosub2Oneand sub2'sdefaultare reachable;deepOnearrives through sub'sexport *).subOneis 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 adeclare modulebody still creates a file-level type-only star re-exportBODY: #2357 routes
export *andexport * as nsinside an ambient body through a whole-module import so the declaring file gains no surface, butexport type * from './impl'(andexport 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-entrysrc/shim.tscarryingdeclare module 'pkg' { export type * from './pair' } export {};(imported for side effects by the entry) reportspair.ts:Foo(interface and const),Bar,plain, anddefault; the interface half ofFooand theBaralias are false positives because the type star forwards them. With the declaration in an entry-point.d.tsinstead, the file-level star launders the consts into the entry surface and onlydefaultreports, a false negative;export type * as nsadditionally reportsshim.ts:nsas 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 creditinginterface Foowithoutconst Foo. Rare form; the extract testambient_module_type_only_star_re_export_keeps_file_level_shapepins 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_referencessuppresses 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 forexport { ns }andexport { 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.tssrc/deep.tssrc/sub.tsVariant A,
src/index.tsVariant B,
src/index.tsVariant C,
src/index.tswith
src/consumer.tsAll 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.tsand have the entry import from it. The non-entry variants of all three credit the full chain on #2379 (src/deep.ts:defaultalone remains, correctly). Also noteexport { ns as default }on the entry does credit the4.
export = nsdoes not credit the target's exports, unlikeexport { 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 = nsandexport default nsdo not.Reproduction, reproduces identically on the current release and on main:
package.json:
src/target.ts:
src/index.ts:
fallow dead-code --format json --quiet --no-cachereportsaandbas unused exports, even though the entry point re-exports the whole module object underexport =. 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 asexport =.Where:
is_re_exportedincrates/graph/src/graph/narrowing.rsmatches only a top-level export whoselocal_nameequals the binding. ATSExportAssignment(and a default-export of a bare identifier) records no suchlocal_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 creditBODY: The exposed-namespace closure deliberately skips a namespace binding that the namespace-object alias phase can narrow (
export const API = { ns }), because that phase followsAPI.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.tssrc/api.tssrc/barrel.tssrc/deep.tsReported:
src/deep.ts:deepOne.Expected: nothing.
Object.keys(ns)in the entry observes the whole namespace object ofbarrel.ts, anddeepOneis on that object throughbarrel'sexport *.Control
Replace
export { ns };insrc/api.tswith a whole-object use inside the same module:and
src/index.tswithWith 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 onobserves_whole_module && (is_whole_object || !is_alias_source). Bothis_whole_objectandis_alias_sourceare 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 whenis_re_exported_from_non_entryis also true would close it, since a re-exported bindi6. 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_reasondoes not consultgraph.ambiguity_participants(), the setfind_unused_exportsdeliberately 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
src/b.ts
src/barrel.ts
src/index.ts
fallow dead-code --format json --quiet --no-cachereports total_issues 1 withunused_exports: [](a duplicate-export finding instead).fallow dead-code --trace src/a.ts:helper --format json --quiet --no-cachereports"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-cachereturns{"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_reasonconsultgraph.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 toImportedName::Namespace, but the binding is never classified for type usage, so only the value namespace is credited. A type export reached throughX.SomeTypein a type position is reported as an unused type even though it is used. The equivalentimport * as X from './m'credits it correctly.Reproduction
package.jsonwith"main": "src/index.ts":{ "name": "repro", "version": "1.0.0", "main": "src/index.ts" }fallow dead-code --format json --quiet --no-cachereportsA,BandCas unused types.AandBare used.Control: replace the consumer line with
import * as T from './t';and onlyCis reported, which is correct.fallow dead-code --trace src/t.ts:A --no-cacheprintsFile: reachable / Namespace: type / Reason: No references found, export is unused.Where
compute_semantic_usage_with_candidatesincrates/extract/src/parse.rsbuildstype_referenced_import_bindingsandvalue_referenced_import_bindingsby iteratingimportsonly, so a require-derived local never appears in either list.desired_import_namespacesincrates/graph/src/graph/narrowing.rstherefore falls through to{ uses_type: false, uses_value: true, classified: false }, andnarrow_namespace_referencesskips theExportNamespace::Typeloop, somark_member_exports_referenced_at_sitenever 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.SomeTypein 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 canonicalimport express = require('express')shape, and most.d.tsuses). 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 NSobject 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, sounused-class-memberandunused-enum-memberrows 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.tssrc/keys.tssrc/index.tsRun
fallow dead-code --no-cache --format json --quiet.Actual
KeysWidgetandKeysModeare correctly credited (nounused-exportrows), but the report still carries:unused_class_members:src/keys-icons.tsKeysWidget.usedandKeysWidget.maybe(kind: class_method)unused_enum_members:src/keys-icons.tsKeysMode.FirstandKeysMode.Second(kind: enum_member)The same rows appear for the bare-pass spelling (
return hand(KeysIcons);instead of theObject.valueswrapper).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_exportsalready abstains them for named and default imports.Cause
build_local_to_export_keysincrates/core/src/analyze/members/mod.rsmaps a local name to export keys only forImportedName::NamedandImportedName::Default;imported_export_namereturnsNoneforImportedName::Namespace. The whole-object loop th9. Entry-point
export { sub } from './barrel'credits anexport * as subtarget's direct exports but not its ownexport *chainBODY: #2373 fixed the case where an entry point reaches an
export * as subbarrel through a plainexport *. The sibling case, where the entry names thesubbinding through a named re-export, still stops atsub's direct exports: the namessubonly exposes through its ownexport * 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.tssrc/barrel.tssrc/sub.tssrc/deep.tsReported:
src/barrel.ts:barrelOne,src/deep.ts:deepOne,src/deep.ts:default.Expected:
src/barrel.ts:barrelOne(not re-exported, correct) andsrc/deep.ts:default(a plainexport *never forwardsdefault, correct).src/deep.ts:deepOneis a false positive:sub.deepOneis on the namespace object the entry exports.subOneandsub.ts:defaultare already credited correctly.Control
Change only
src/index.tstoand the same tree reports only
src/deep.ts:defaultafter #2372/#2373. The single difference between the two projects is whether the entry reaches the barrel through a plainexport *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 plainexport *chains only, then seeds theexport * assources found on that surface. A barrel reached by a named re-export is never on the surface, sosubis never a closure member and its ownexport *chain is never followed. Phase 2c still creditssub's direct exports because the entry point itself satisfiesexposes_namespace_object.Reproduces identically on main
e65a9083eand on PR #2379 head1a81fc18e, so this is pre-existing. It is the export-side sibling of the Phase 2c consumer gap already10. An entry point that exposes an
export * asnamespace through a named re-export still stops at the first namespace levelBODY: Sibling of #2373 that #2379 does not close.
ModuleGraph::collect_exposed_namespace_targetsseeds the entry-surface half of the closure from theexport * assources of entry points and of barrels an entry reaches through plainexport *. 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.tssrc/barrel.tssrc/sub.tssrc/deep.tssrc/sub2.tsReported:
src/deep.ts:deepOne,src/deep.ts:default,src/sub2.ts:sub2One,src/sub2.ts:default.Expected: only
src/deep.ts:default(a plainexport *never forwardsdefault;sub.deepOnearrives through sub'sexport *, andsub.sub2is sub2's namespace object so both of its exports are reachable).sub.ts:subOneandsub.ts:defaultare 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 ownexposes_namespace_objectverdicts are never fed back into it. A fixpoint or pre-pass over the reachable-barrel sets would close both in one rule.11.
export * asbehind an ambiguous duplicate-star name credits the namespace target's exportsBODY: 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 theexport * asnamespace 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.tssrc/a.tssrc/b.tssrc/target-a.tssrc/target-a-deep.tsExpected:
src/a.ts:dup,src/target-a.ts:targetAOneandsrc/target-a-deep.ts:targetADeepOneare all unreachable (index.dupis 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_barrelsplusuniquely_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,
targetADeepOneis 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.tssrc/barrel.tssrc/deep.tssrc/orphan.tsReported:
src/orphan.tsas an unused file, andsrc/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:barrelTworeported as well, since deletingorphan.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_referencesdoes not consult entry-point reachability forsite.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 wholeexport *andexport * aschain (deepOneis 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 MCPproject_infotool, and thelist/workspacesinventory 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:
package.json:{ "name": "repro-project-info-stale", "private": true, "overrides": { "ws": "^8.21.0" } }bun.lockb(any non-empty content):src/index.ts:fallow-mcp, sendinitializeand thenotifications/initializednotification, then over the SAME stdio connection:analyzewith{"root": "<project>", "no_cache": true}->workspace_diagnostics=["bun-lockb-override-resolution-skipped"]project_infowith{"root": "<project>"}->["bun-lockb-override-resolution-skipped"](correct, the cause is still there)bun.lock:{"lockfileVersion":1,"workspaces":{"":{"name":"repro-project-info-stale"}},"packages":{"ws":["ws@8.21.3","",{},"sha512-20"]}}project_infoagain with{"root": "<project>"}Observed: the third
project_infostill returns["bun-lockb-override-resolution-skipped"]even though the cause is fixed. A followinganalyzecorrectly returns[], and aproject_infoafter that also returns[].Expected: either
project_inforefreshes 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 (
analyzeand every laterproject_infokeep returning it), so #2366 strictly narrows this window rath14. 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 *andexport * aschain.Project:
package.json
{ "name": "pa", "type": "module", "main": "src/index.ts" }src/index.ts
src/b.ts
src/c.ts
src/u.ts, which nothing imports
unused_filescontainssrc/u.ts.unused_exportsdoes not containsrc/b.ts:bTwoon any version, and on the current build it does not containsrc/c.ts:cOneeither.Expected:
src/b.ts:bTwoandsrc/c.ts:cOneare 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_namespacesselects (crates/graph/src/graph/narrowing.rs). The binding is value-only, somark_all_exports_referenced_at_sitenever 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:src/index.ts:fallow dead-code --format json --no-cachereportsunused-type ReqTypeonsrc/re-t.ts, whilereqValueon 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 withexport 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-typefor a binding declared inside adeclare 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 unusedBODY:
compute_semantic_usage_with_candidatesresolves import bindings withscoping.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, anddesired_import_namespacesfalls through to the value-only default. A type reached through the binding is then left uncredited.Repro,
package.jsonwith"main": "src/index.ts":fallow dead-code --format json --no-cache --quietreportsEsmThingonsrc/bar-esm.tsas an unused type, even thoughexport const b: BarEsm.EsmThingreferences 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 anythingBODY: An ESM namespace import whose binding is never referenced is recorded in
unused_import_bindings,is_unused_import_bindingshort-circuitsattach_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.jsonwith"main": "src/index.ts":fallow dead-code --format json --no-cache --quietreportsc1andc2onsrc/t-esm.tsand nothing at all onsrc/t-req.ts. Both bindings are equally dead.Reproduces identically on the pre-#2365 binary for the
constform. #2365 extends the same leniency toimport 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 anunused-filerow for the target.Likely fix: record an unreferenced non-destructured require binding as an unused import binding, keeping the
export importform 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 unusedBODY:
is_re_exportedhas no arm forTSExportAssignment, so a file that imports a module namespace and hands it on withexport = Xcredits 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:src/index.ts(entry):fallow dead-code --format json --quiet --no-cachereportssrc/inner.tsalphaandsrc/inner.tsbetaas unused exports. Both are reachable: a consumer of the package writesrequire('pkg').alpha.The same happens through a
.d.tshop:src/m.ts:src/api.d.ts:src/index.ts:reports
alphaandbetaonsrc/m.ts, even thoughApi.alphais read.Expected
export = X, whereXis an import binding, should credit the target the wayexport { 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_exportedhas noTSExportAssignmentarm. Surfaced while reviewing the fix for #2365; it is independent of that change and reproduces identically on the pre-#2365 binary and for the plainimport * as Xspelling.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 NSlocal 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 byawait import(...),import(...).then(m => ...), orconst m = require('...')reach the samenarrow_namespace_referencesnarrowing 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:src/index.ts:fallow dead-code --no-cache --format jsonreportssrc/icons.ts:Moon. It is reachable:registerreceives 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
.vuesingle-file component that hands animport * as NSlocal 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/.tsxequivalent 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.tssrc/Widget.vuesrc/PanelVue.vuesrc/index.tsfallow dead-code --no-cache --format jsonreportssrc/icons.ts:Moonandsrc/icons.ts:Sun. Both are reachable::icons="VueIcons"hands the whole namespace object toPanel, which can read any export off the prop.Expected: no finding, the same guarantee
.tsxgets 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 NSlocal a whole-object use. Namespace objects bound byrequire()or byawait 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.tsandsrc/dyn-icons.ts(identical content)src/req.tssrc/dyn.tssrc/index.tsfallow dead-code --no-cache --format jsonreportssrc/req-icons.ts:Moonandsrc/dyn-icons.ts:Moon. Both are reachable throughconsume(...).Expected: no finding, matching the
import * asbehaviour.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.