
A TypeScript import written against a barrel file (import { books } from '../db/schema', where db/schema/index.ts only does export * from './library') names a symbol the barrel never declares. A code graph that stops at the barrel can't answer "who uses books?", because every importer of every schema symbol points at the same index.ts. Since bin-v0.6.3, CGraph follows export * and named re-exports to the file that actually declares the name, so impact analysis reaches the code that really depends on it.
Why a barrel hides every user
Barrels are everywhere in TypeScript codebases: components/index.ts, db/schema/index.ts, lib/index.ts. They exist so imports stay short, and they work by forwarding names they never declare:
// db/schema/index.ts
export * from "./library";
export * from "./users";
// db/index.ts
export { books as catalogBooks } from "./schema";
The extractor already emitted re_exports edges for these files. What was missing was the step from "this import lands on a barrel" to "this import means that declaration". Without it, an imports edge for books ended at db/schema/index.ts, and so did the edge for users and every other schema symbol.
That breaks impact analysis in both directions at once. A real user of books is only reachable by walking through the barrel, which costs depth. A file that imports something unrelated from the same barrel looks just as connected.
What CGraph does now
The change has two halves (the full proposal and spec).
The extractor marks the edge. A bare export * from './x' gets star=true. export { a as b } from './x' gets reexport=b. export * as ns from './x' gets no star mark: it creates a namespace object, not a pass-through, so following it would be wrong. The marks sit on the per-file edge because stubs are shared across importing files.
The resolver follows the marks. When an import still sits on a barrel after the named chain is resolved, it follows star targets breadth-first to the file that declares the name exactly once. Three rules keep that honest:
- A declaring file stops the search. A file's own exports shadow its star exports, the same way TypeScript resolves them.
- A named re-export shadows star targets, and the follow switches to the target's name at each hop, so alias chains work.
- The walk is bounded at 8 hops and is cycle-safe. A star cycle terminates with the import left on the barrel.
A name found nowhere stays on the barrel, as before. The resolver points at a declaration only when it can prove one, which is the same rule as refusing to guess a call target.
Try it on ten files
Here's a small project with the patterns above, plus a component barrel (export { default } from './Button' alongside export * from './Button'), built with bin-v0.6.2 and with the current bin-v0.7.1. These are the imports edges from the service and UI files:
bin-v0.6.2 bin-v0.7.1
services/accounts.ts -> schema/index.ts services/accounts.ts -> users
services/catalog.ts -> schema/index.ts services/catalog.ts -> bookTitle
services/legacy.ts -> db/index.ts services/catalog.ts -> books
ui/page.ts -> components/index.ts services/legacy.ts -> books
ui/page.ts -> buttonSize
imports stuck on a barrel: 4 imports stuck on a barrel: 0
legacy.ts imports catalogBooks from db/index.ts, which re-exports books under a new name from a barrel that stars it in. That's two hops and an alias, and it lands on books.
The difference shows up in impact. Asking for the dependents of books:
bin-v0.6.2 | bin-v0.7.1 | |
|---|---|---|
services/catalog.ts (uses books) | depth 3, via library.ts and the barrel | depth 1, imports |
services/legacy.ts (uses books as catalogBooks) | missing (past the depth limit) | depth 1, imports |
services/accounts.ts (uses only users) | depth 3, imports, same as a real user | depth 3, imports_from the barrel |
Before, the real users were three hops away or missing entirely, and an unrelated file looked exactly as connected as a real one. After, both real users are direct dependents.
Measured on a real service
On a TypeScript API service with a Drizzle schema behind barrel files, comparing bin-v0.6.2 with the change. The first two numbers are from after review; the sampled-table count is from the first push:
Every one of the 9 importers of the sampled table was confirmed in source.
The review found two real bugs before merge, and both are worth knowing if you write this kind of resolver yourself:
- The usual component barrel lost its star.
export { default } from './x'plusexport * from './x'produces two edges to the same file, and the fragment merge kept one and dropped the other's marks. That's now merged into the kept edge. - A second alias of the same name was lost.
export { a as one, a as two }kept onlyone.
The graph's index version moved to logic-6, so a daemon won't fast-load a graph the previous binary built.
What it doesn't fix
Two limits are worth stating plainly.
File-level edges still over-approximate. In the demo, accounts.ts still appears in the dependents of books, through imports_from the barrel file. On the real service, at the first push, impact from the sampled table reached 165 module files, up from 1, and 224 of the reached module nodes come through one path: a service imports the table, a module barrel re-exports that service, and other files imports_from that barrel. That's the existing file-level semantics of imports_from, which this change exposes rather than introduces. Tightening it is a separate traversal change.
A name needs a node to land on. In a first version of the demo, Button.ts exported const buttonSize = 12. That primitive constant has no node in the graph, so the import stayed on components/index.ts even in bin-v0.7.1. Exporting it as a function gave it a node, and the import resolved. A barrel can only forward an import to a declaration the extractor recorded.
Takeaway
If your code-intelligence tool shows index.ts as the most-depended-on file in your repo, it's probably stopping at barrels. Following export * is a resolution step, not a heuristic: mark the edge, follow it only to a file that declares the name exactly once, and leave the import where it was when nothing does. CGraph has done this since bin-v0.6.3, and the source and releases are on GitHub.