Goal

The convention is documented and the build fails with file:line when one module deep-imports another's internals — with no DI, no decorators, no runtime cost.

Definition of done

  • A written spec for the module convention: app/<name>/index.ts (public API), routes.ts (HTTP surface), everything else private.
  • A build-time check that classifies each import: core/npm/same-module (allow), another module's index.ts (allow), any deeper path into another module (error).
  • Errors carry file:line and a fix hint ("reach it through ../billing or export it from its index").
  • Correct resolution of relative paths and configured path aliases; re-export barrels don't create false negatives.
  • Zero runtime footprint — the check runs at build only.

The convention

Decision to settle here: the module root (app/* by default) and whether nested feature trees are allowed. Documented as a draft spec under framework/draft.

app/
  billing/
    index.ts            # public API — the only legal cross-module import target
    routes.ts           # export const routes = { ... }  (Bun.serve shape)
    invoice.service.ts  # private
  _platform/
    index.ts            # shared infra (db, scheduler) via its own public API

The boundary check

New: a build pass (run inside, or alongside, the discovery macro) that reads import specifiers and applies one rule. No metadata, no annotations — derived from folder layout + the import graph.

// pseudo-rule
for (const imp of file.imports) {
  const target = resolve(imp, file);
  const owner = moduleOf(target);          // which app/<name>/ owns it, if any
  if (owner && owner !== moduleOf(file) && !isIndexOf(target, owner))
    error(`${file}:${imp.line} — deep import into module '${owner}'.`);
}

Verify

bun --filter patties test boundary   # allow public-index import; reject deep import; aliases resolve

Dependencies

Blocked by

Nothing — this is the foundation.

Blocks

E2 (the route macro runs the same discovery + check) and E3 (the starter ships modules that obey the rule).