Skip to content

Boundary rules, forbidden calls and coverage.requireAllFiles silently skip files that are unreachable from an entry point #2937

Description

@tmak

Summary

Boundary import rules, boundaries.calls.forbidden and boundaries.coverage.requireAllFiles are only evaluated for files that fallow considers reachable from an entry point (or are entry points). A file that sits inside a zone but is not reachable is never checked, so its forbidden imports and forbidden calls pass silently.

This is easy to hit with scripts that are run by a task runner fallow does not read (mise, make, a justfile, a CI step calling node scripts/foo.ts, and so on). Those files are "unused" to fallow, so they are outside the boundary check even though they are real, executed code.

Whether the gap is visible depends on the rest of the config:

  • With the default rules, the file is at least reported as an unused-file, which hints at the problem.
  • With fallow dead-code --boundary-violations (the documented CI invocation for boundaries), or with unused-files turned off so boundaries can be adopted on their own, nothing reports the file at all. The run is green.
  • The existing warning boundary zone '<zone>' matched 0 reachable files only fires when every file in a zone is unreachable. A zone where some files are reachable and some are not gives no signal.

fallow guard <file> does report the zone and the forbidden calls for such a file, which suggests that the rules apply to it. The analysis then skips it.

Minimal reproduction

fallow 3.29.0 (latest at time of writing), macOS arm64, run via npx -y fallow@3.29.0. No node_modules needed.

package.json

{
  "name": "unreachable-boundary-repro",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "src/index.ts",
  "scripts": {
    "task:listed": "node tools/listed-task.ts"
  }
}

.fallowrc.json

{
  "boundaries": {
    "zones": [
      { "name": "app", "patterns": ["src/app/**", "src/index.ts"] },
      { "name": "secret", "patterns": ["src/secret/**"] },
      { "name": "tools", "patterns": ["tools/**"] }
    ],
    "rules": [
      { "from": "app", "allow": [] },
      { "from": "tools", "allow": [] },
      { "from": "secret", "allow": [] }
    ],
    "calls": {
      "forbidden": [
        { "from": "app", "callee": "child_process.*" },
        { "from": "tools", "callee": "child_process.*" }
      ]
    }
  }
}

src/index.ts

import { run } from "./app/main.ts";
run();

src/app/main.ts

import { exec } from "node:child_process";
import { readSecret } from "../secret/store.ts";

export function run(): void {
  exec("echo reachable");
  console.log(readSecret());
}

src/secret/store.ts

export function readSecret(): string {
  return "s3cr3t";
}

tools/listed-task.ts (reachable: referenced from a package.json script)

import { exec } from "node:child_process";
import { readSecret } from "../src/secret/store.ts";

exec("echo listed");
console.log(readSecret());

tools/orphan-task.ts (identical code, but only run by an external task runner, so not reachable)

import { exec } from "node:child_process";
import { readSecret } from "../src/secret/store.ts";

exec("echo orphan");
console.log(readSecret());

Run

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

Actual

  2 entry points detected (2 package.json)

● Boundary violations (2)
  src/app/main.ts:2 → src/secret/store.ts (app → secret)
  tools/listed-task.ts:2 → src/secret/store.ts (tools → secret)

● Boundary calls (2)
  src/app/main.ts:5 exec matches forbidden `child_process.*` in zone 'app'
  tools/listed-task.ts:4 exec matches forbidden `child_process.*` in zone 'tools'

✗ 2 violations

tools/orphan-task.ts has the same tools → secret import and the same child_process call, and neither is reported. Plain fallow dead-code lists it only under "Unused files".

fallow guard tools/orphan-task.ts meanwhile says:

tools/orphan-task.ts (zone: tools)
  may import zones: tools (same zone)   type-only: none
  forbidden calls in zone: child_process.*

fallow inspect --file tools/orphan-task.ts shows "is_reachable": false.

coverage.requireAllFiles has the same gap

Add "coverage": { "requireAllFiles": true } under boundaries, plus two unzoned files: misc/unzoned-reach.ts (imported from src/index.ts) and misc/unzoned-orphan.ts (imported by nothing). Only misc/unzoned-reach.ts gets a Boundary coverage finding. The docs describe the setting as "Report every analyzed source file that matches no zone".

Only when every file in a zone is unreachable

If the task:listed script is removed, so that no file in tools is reachable, fallow warns boundary zone 'tools' matched 0 reachable files .... With one reachable file in the zone, as above, there is no warning.

Workaround that works

Seeding the files as entry points makes them checked:

{ "entry": ["tools/**/*.ts"] }

or "dynamicallyLoaded": ["tools/**/*.ts"]. With either, tools/orphan-task.ts gets both findings. A catch-all such as "entry": ["**/*.ts"] also makes requireAllFiles report misc/unzoned-orphan.ts. The catch-all only makes sense when dead-code findings are not used, because it credits every export as used.

Expected

One of the following, in order of preference:

  1. Boundary, forbidden-call and coverage checks run on every analyzed file that matches a zone, reachable or not. An unreachable file's imports are still code that someone runs or will run, and the direction rules say nothing about reachability. Where a separate unused-files finding for the same file is too much noise, it could be a documented opt-out, not the default.
  2. An opt-in setting, e.g. boundaries.checkUnreachable: true (or boundaries.scope: "all" | "reachable"), which lets users who adopt boundaries on their own get full coverage without seeding every file as an entry point.
  3. At minimum, make the current behaviour visible:
    • document on the boundaries configuration page (import rules, calls, coverage) that only files reachable from an entry point are checked, and name entry / dynamicallyLoaded as the way to include task-runner scripts;
    • have fallow guard say when the file is currently unreachable and its rules will not be enforced;
    • make the "matched 0 reachable files" warning (or a new one) fire when a zone contains unreachable files, not only when it contains no reachable ones, at least under --boundary-violations.

Where this happens

The same guard is at the top of all three analyzers:

  • crates/core/src/analyze/boundary.rs, collect_node_boundary_violations: if !node.is_reachable() && !node.is_entry_point() { return; }
  • crates/core/src/analyze/boundary_calls/mod.rs, boundary_call_scan_target: same condition, return None
  • crates/core/src/analyze/boundary_coverage.rs: same condition, continue

I could not find this documented on https://docs.fallow.tools/configuration/boundaries or https://docs.fallow.tools/analysis/boundaries. The only hint is the wording of the empty-zone warning.

Environment

  • fallow 3.29.0 (npm), also checked against main at 35f17b1
  • macOS arm64 (Darwin 25)
  • Node via npx; the fixture has no dependencies and no node_modules (fallow prints its usual warning about that). The skip comes from the reachability guard quoted above, not from dependency resolution.

Activity

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

    @BartWaardenburg
    Collaborator

    In fallow 3.31.0, tools/orphan-task.ts gets the same boundary findings as tools/listed-task.ts when you run dead-code --boundary-violations. Import rules, boundaries.calls.forbidden and boundaries.coverage.requireAllFiles now check every analyzed file, also a file that no entry point reaches. Such a file can keep its unused-files finding too. The zone warning is now matched 0 files, and it fires only for a zone that matches no analyzed file.

    This change can add findings to an existing configuration. To keep the old result, save a baseline with --save-baseline, or add // fallow-ignore-file boundary-violation to the file.

    Upgrade with npm install fallow@3.31.0. Thanks for the report.

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