Threads · 18 / 18 · Sep 7, 2026 · Apache-2.0
Your open-core boundary check is blind three ways
You declared which files are public. You wrote a check that they do not import anything private. It passes. The tree it approves does not compile.
Five tests. The first says the three obvious checks all pass. The second copies the files they approved into an empty directory and asks TypeScript to build them, and it fails. The rest show the fix.
The three blind spots
You are extracting part of a private monorepo into a public repository. You list the files that go out and you write a check. Whichever of these three you wrote, it passes on the fixture in this repository:
| The check you wrote | Why it is reasonable | What it cannot see |
|---|---|---|
| Direct imports — does a declared file import something private? | It is the literal question | It never follows the chain. A declared file importing the file next door looks innocent, and the file next door is where the leak is |
| Declared vs imported — do the manifest's dependencies match what is imported? | It catches a missing dependency | The private package is a workspace sibling, not a dependency. It is absent from both lists, so the two lists agree perfectly |
What actually runs — strip import type, then look |
A type disappears at runtime, so it cannot be a runtime dependency | It is right about the runtime and wrong about the build — and publishing is a build |
Each is correct about the question it asks. The problem is that none of them asks the question that decides: does the declared set REACH anything private, by any path, including through types?
The test is the attempt to compile it
Everything above is argument. This is what settles it:
test("and the tree they approved DOES NOT COMPILE outside its monorepo", () => {
const { ok, output } = compileInIsolation(DECLARED);
expect(ok).toBe(false);
expect(output).toContain("./payload.ts");
});
compileInIsolation copies the declared files into an empty temporary directory — which is what publishing a
repository is — and runs tsc. There is no way to argue with the result.
And the last two tests are the other half of the bar: with the shape extracted into a contract that imports
nothing, the same tree compiles, and the transitive check agrees with tsc. Red without the fix, green
with it. A gate that has never been red proves nothing.
What the transitive check has to do
Three properties, none of them optional:
- Follow the chain to a fixed point. A clean file that imports a clean file that imports the database is not clean.
- Count
import type.tscdoes. A type missing at build time is missing whether or not it survives to runtime. - Report the chain, not the verdict. "This fails" costs somebody half an hour finding the hop. It also has to keep walking past the first undeclared file: "this file is not declared" and "this file opens the database" call for two different fixes, and stopping at the first one hides the second.
The fix is smaller than the problem
The shape moves to a file with zero imports, and the declared file takes it from there. That file is now publishable because it reaches nothing; the private side keeps a compile-time assertion that the two shapes are still identical, so the day they drift, what breaks is the side that can still fix it.
Zero imports is not a style preference. It is the only property that makes a file portable, and it is worth protecting with a check, because the import that breaks it will be added by someone with a good reason.
Where this comes from
This is not hypothetical. Both halves happened while extracting packages out of niiko:
- A set of six files was declared public after measuring their direct imports, and they came out clean. The leak was two hops away — a declared file imported a sibling, and the sibling imported a type from the database package.
- The same tree later failed to compile in isolation for a second, unrelated reason: a type that was
narrower in a newer version of
@types/nodethan in the version the monorepo had pinned. Inside, it built. Outside, it did not.
The second one is the argument for the test rather than the check: a boundary check can only find the leaks you taught it to look for. Compiling the tree in an empty directory finds the ones you did not.
License
Apache-2.0 — see LICENSE. This is a demonstration, not a package. If you want the transitive
check in your own build, copy checks/transitive.ts; that is what the licence is for.
Built by Vorluno — a software studio from Panamá.
// next threadA cap in the wrong unit is not a cap you forgot to check