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:
- 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.
- 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.
- 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.
Summary
Boundary import rules,
boundaries.calls.forbiddenandboundaries.coverage.requireAllFilesare 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, ajustfile, a CI step callingnode 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:
unused-file, which hints at the problem.fallow dead-code --boundary-violations(the documented CI invocation for boundaries), or withunused-filesturned off so boundaries can be adopted on their own, nothing reports the file at all. The run is green.boundary zone '<zone>' matched 0 reachable filesonly 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. Nonode_modulesneeded.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.tssrc/app/main.tssrc/secret/store.tstools/listed-task.ts(reachable: referenced from apackage.jsonscript)tools/orphan-task.ts(identical code, but only run by an external task runner, so not reachable)Run
Actual
tools/orphan-task.tshas the sametools → secretimport and the samechild_processcall, and neither is reported. Plainfallow dead-codelists it only under "Unused files".fallow guard tools/orphan-task.tsmeanwhile says:fallow inspect --file tools/orphan-task.tsshows"is_reachable": false.coverage.requireAllFileshas the same gapAdd
"coverage": { "requireAllFiles": true }underboundaries, plus two unzoned files:misc/unzoned-reach.ts(imported fromsrc/index.ts) andmisc/unzoned-orphan.ts(imported by nothing). Onlymisc/unzoned-reach.tsgets aBoundary coveragefinding. 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:listedscript is removed, so that no file intoolsis reachable, fallow warnsboundary 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.tsgets both findings. A catch-all such as"entry": ["**/*.ts"]also makesrequireAllFilesreportmisc/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:
unused-filesfinding for the same file is too much noise, it could be a documented opt-out, not the default.boundaries.checkUnreachable: true(orboundaries.scope: "all" | "reachable"), which lets users who adopt boundaries on their own get full coverage without seeding every file as an entry point.calls,coverage) that only files reachable from an entry point are checked, and nameentry/dynamicallyLoadedas the way to include task-runner scripts;fallow guardsay when the file is currently unreachable and its rules will not be enforced;--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 Nonecrates/core/src/analyze/boundary_coverage.rs: same condition,continueI 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
mainat35f17b1npx; the fixture has no dependencies and nonode_modules(fallow prints its usual warning about that). The skip comes from the reachability guard quoted above, not from dependency resolution.