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:

  1. Follow the chain to a fixed point. A clean file that imports a clean file that imports the database is not clean.
  2. Count import type. tsc does. A type missing at build time is missing whether or not it survives to runtime.
  3. 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:

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