Skip to content

Boundary rules do not follow re-export chains (docs say they do) #2939

Description

@tmak

Summary

The docs say boundary checks follow re-export chains:

Fallow follows re-export chains when it checks boundaries. If shared/index.ts re-exports a symbol from core/, fallow treats an import of that symbol as an import from core/, not shared/.
(https://docs.fallow.tools/explanations/fallow-vs-linters#architecture-boundary-violations; the comparison table on the same page also says "Fallow: ... follows re-export chains")

In 3.29.0 this does not happen. A ui file that imports a core symbol through a shared barrel gets no boundary-violation. A direct ui -> core import in the same project is flagged. The result is the same for named re-exports and export *, and for relative and tsconfig-alias specifiers.

Fallow does resolve the chain for dead-code analysis. dead-code --trace lists the ui files as direct references of the core export, re-exported through the shared barrels. Only the boundary pass ignores the chain.

Reproduction

Directory layout:

package.json
tsconfig.json
.fallowrc.json
src/index.ts
src/core/direct.ts
src/core/secret.ts
src/shared/util.ts
src/shared/named-relative.ts
src/shared/star-relative.ts
src/shared/named-alias.ts
src/shared/star-alias.ts
src/ui/control-direct.ts
src/ui/allowed-shared.ts
src/ui/via-named-relative.ts
src/ui/via-star-relative.ts
src/ui/via-named-alias.ts
src/ui/via-star-alias.ts

package.json

{
  "name": "reexport-boundaries-repro",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "src/index.ts"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}

.fallowrc.json: the same zones and rule as the docs example, plus core:

{
  "entry": ["src/index.ts"],
  "boundaries": {
    "zones": [
      { "name": "ui", "patterns": ["src/ui/**"] },
      { "name": "core", "patterns": ["src/core/**"] },
      { "name": "shared", "patterns": ["src/shared/**"] }
    ],
    "rules": [{ "from": "ui", "allow": ["shared"] }]
  }
}

src/core/secret.ts

export const coreValue = 1;
export const coreStar = 2;

src/core/direct.ts

export const coreDirect = 3;

src/shared/util.ts

export const sharedValue = 4;

src/shared/named-relative.ts

export { coreValue } from "../core/secret";

src/shared/star-relative.ts

export * from "../core/secret";

src/shared/named-alias.ts

export { coreValue } from "@/core/secret";

src/shared/star-alias.ts

export * from "@/core/secret";

src/ui/control-direct.ts: the control case, which is flagged as expected

import { coreDirect } from "../core/direct";
export const a = coreDirect;

src/ui/allowed-shared.ts: a genuine shared import that is allowed

import { sharedValue } from "../shared/util";
export const b = sharedValue;

src/ui/via-named-relative.ts

import { coreValue } from "../shared/named-relative";
export const c = coreValue;

src/ui/via-star-relative.ts

import { coreStar } from "../shared/star-relative";
export const d = coreStar;

src/ui/via-named-alias.ts

import { coreValue } from "@/shared/named-alias";
export const e = coreValue;

src/ui/via-star-alias.ts

import { coreStar } from "@/shared/star-alias";
export const f = coreStar;

src/index.ts

export { a } from "./ui/control-direct";
export { b } from "./ui/allowed-shared";
export { c } from "./ui/via-named-relative";
export { d } from "./ui/via-star-relative";
export { e } from "./ui/via-named-alias";
export { f } from "./ui/via-star-alias";

Command:

npx -y fallow@3.29.0 dead-code --boundary-violations

Expected

Five boundary violations: the direct control import, plus the four ui files that consume a core symbol through a shared re-export. The docs say these should count as imports from core.

src/ui/control-direct.ts:2      → src/core/direct.ts (ui → core)
src/ui/via-named-relative.ts:1  → src/core/secret.ts (ui → core)
src/ui/via-star-relative.ts:1   → src/core/secret.ts (ui → core)
src/ui/via-named-alias.ts:1     → src/core/secret.ts (ui → core)
src/ui/via-star-alias.ts:1      → src/core/secret.ts (ui → core)

src/ui/allowed-shared.ts should stay clean.

Actual

Only the direct import is reported:

● Boundary violations (1)
  src/ui/control-direct.ts:2 → src/core/direct.ts (ui → core)

✗ 1 violation

The re-export edges do resolve. If I add { "from": "shared", "allow": [] }, the four shared -> core re-export lines are reported, for both the alias and the relative forms. Nothing is reported as unresolved or unused, and --trace shows that fallow knows who consumes the symbol:

$ npx -y fallow@3.29.0 dead-code --trace src/core/secret.ts:coreValue
  USED coreValue in src/core/secret.ts
  Reason: Used by 2 file(s), re-exported through 4 barrel(s)
  2 direct reference(s):
    -> src/ui/via-named-alias.ts (named import)
    -> src/ui/via-named-relative.ts (named import)
  Re-exported through:
    -> src/shared/named-alias.ts as 'coreValue' (1 ref(s))
    -> src/shared/named-relative.ts as 'coreValue' (1 ref(s))
    -> src/shared/star-alias.ts as 'coreValue' (0 ref(s))
    -> src/shared/star-relative.ts as 'coreValue' (0 ref(s))

Environment

  • fallow 3.29.0 (the npm prebuilt, @fallow-cli/darwin-arm64, signature verified)
  • macOS arm64
  • node_modules is not installed in the fixture (fallow warns about this). The fixture has no package imports.
  • I also checked the source at the v3.30.0 release commit: crates/core/src/analyze/boundary.rs did not change in that release, so 3.30.0 should behave the same.

Cause (from reading the source)

find_boundary_violations in crates/core/src/analyze/boundary.rs loops over graph.outgoing_edge_summaries(node.file_id) and classifies only the direct edge.target. It never looks at the target's re_exports (ReExportEdge { source_file, imported_name, exported_name, .. }). An import of a barrel is therefore judged by the barrel's zone. No test in crates/core/tests/integration_test/boundary_violations.rs or in the tests/fixtures/boundary-* fixtures covers a re-export chain.

Possible fix

For each edge whose target module re-exports the imported symbols, follow each symbol through the target's re-export edges (using the resolution the unused-exports pass already does, including export * and cycle guards) to the module that defines it. Then run is_import_allowed(from_zone, origin_zone) on the origin as well as on the direct target. Report the violation at the import line, and possibly name the barrel in the message, e.g. ui → core (via src/shared/named-alias.ts).

Two points to consider:

  • Unzoned barrels. Support auto-discovery of architecture boundaries #368 says top-level features/index.ts barrels "stay unclassified by design so barrels can re-export children without false positives". With chain-following, a sibling feature that imports features/index.ts would be attributed to the child feature it uses. That is probably the intended result, but it changes findings for existing bulletproof users, so it may need a changelog note, or an opt-in or opt-out setting at first.
  • Type-only re-exports (export type { X } from) should keep allowTypeOnly semantics along the chain.

If the current behaviour is intended, the docs should be corrected instead. The "follows re-export chains" claim on the fallow-vs-linters page is the main thing that separates fallow from eslint-plugin-boundaries and no-restricted-paths in that comparison.

Related

Activity

  1. added a commit that references this issue on Sep 27, 2026
    7e16ec2
  2. BartWaardenburg commented on Sep 30, 2026

    @BartWaardenburg
    Collaborator

    With fallow 3.31.0 (npm install fallow@3.31.0), a ui file that imports a core symbol through a shared barrel gets a boundary-violation. Fallow now judges a named or default import against the zone of the module that declares the symbol, so the behavior matches the docs.

    The finding names the origin module in to_path and to_zone. The new optional via_path field names the barrel, and the human, SARIF, CodeClimate, markdown and LSP messages name it too. Baseline keys stay from_path->to_path. Fallow still judges namespace and side-effect imports by the direct target. This can add findings to an existing configuration, and --save-baseline keeps the old result.

    Thanks for the report and the side-by-side cases.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions