Skip to content

Enforce per-folder public-API boundaries via dependency-cruiser #926

@srid

Description

@srid

Goal: make every multi-file feature folder under packages/client/src/ declare an explicit public surface (a barrel index.ts) and let dependency-cruiser enforce that outside callers reach only through it. Internal modules stop being callable by accident; the folder becomes a real unit of refactoring.

This is the boundary discussion that came up during #922 review. The conclusion: the right tool for kolu is dependency-cruiser (already being introduced on the depcruise-setup branch — must land first), not Biome (which only restricts which modules are importable globally, not who imports them).

Depends on

  • depcruise-setup branch merged (PR TBD). This issue's enforcement step plugs a new rule into .dependency-cruiser.mjs.

Candidate folders

Folders inside packages/client/src/ that have ≥5 files, a discrete concern, and a narrow public surface today (the actual external imports — most files are internal-by-convention):

Folder Files Likely public surface
canvas/ ~23 TerminalCanvas, the public hooks (useCanvasArrange, useCanvasFocus), the dock model
terminal/ ~14 TerminalContent, AgentIndicator, ChecksIndicator, SubPanelTabBar, SearchBar
right-panel/ ~14 RightPanel, RightPanelLayout, openInCodeTab, useRightPanel
comments/ 12 CommentComposer, CommentsTray, CommentTextSurface, CommentIframeSurface, useCommentScrollRequest (5 names)
recorder/ 6 RecordButton, RecordPopover
input/ 6 useShortcuts, actions, platform helpers
settings/ 5 SettingsPopover, tips, useColorScheme, useTips

ui/ is intentionally a broad component library and stays out. rpc/ and debug/ are too small to be worth a barrel.

Per-folder mechanical recipe

  1. Create <folder>/index.ts re-exporting only the names imported from outside the folder today. grep -rn "from \"\\.\\./<folder>/" packages/client/src/ | grep -v "^packages/client/src/<folder>/" enumerates the de-facto surface.

  2. Rewrite the external import sites to from "../<folder>" (no deep paths).

  3. Add a rule to .dependency-cruiser.mjs (one rule covers all folders via a from/to pattern with back-substitution — see existing integrations-no-siblings for the pattern):

    {
      name: "client-feature-folder-encapsulation",
      severity: "warn",        // promote to "error" once findings clear
      comment:
        "Outside a client feature folder, imports must go through the " +
        "folder's barrel — no deep imports into individual files.",
      from: {
        path: "^packages/client/src/(canvas|terminal|right-panel|comments|recorder|input|settings)/.+",
        // exempt files inside the same folder
      },
      to: {
        path: "^packages/client/src/(canvas|terminal|right-panel|comments|recorder|input|settings)/[^/]+",
        pathNot: "^packages/client/src/[^/]+/index\\.ts$",
      },
    }

    The from/to capture-group trick from integrations-no-siblings lets us write one rule rather than seven. The exact regex needs care — the rule must fire only on cross-folder edges, not folder-internal ones.

  4. Ratchet: start at warn, fix the violations in batches, flip to error once each folder is clean.

Out of scope

  • packages/client/src/*.ts(x) top-level files — they're already the de-facto barrel. App.tsx pulls them directly; that's fine.
  • Promoting any folder to its own workspace package. Boundary enforcement gets us 90% of the benefit at 5% of the cost. Promote only if a folder actually wants to be reused outside the client app.
  • packages/server/, packages/integrations/* — the layer rules already in .dependency-cruiser.mjs (anyagent-leaf, integrations-no-siblings, client-no-integration-runtime) cover the cross-package axis. This issue is specifically about feature folders inside a single package.

Notes from #922

The comments/ folder is the worked example. Its actual public surface (from the PR-review investigation):

  • CommentComposer, CommentsTray, CommentTextSurface, CommentIframeSurface, useCommentScrollRequest

Everything else (useComments, useTextSelection, highlightOverlay, formatMarkdown, SelectionPill, Comment/PersistedShape types, useComposer) is consumed only inside the folder. A barrel of 5 lines would lock that down — but only the dependency-cruiser rule turns it from convention into a CI gate.

Why dependency-cruiser, not Biome

biome lint's noRestrictedImports only restricts which modules are importable globally; it has no equivalent of ESLint's no-restricted-paths (the importer-location axis), so it can't express "anything outside comments/ may not deep-import into it." dependency-cruiser does this natively via from/to path patterns, and gets us the architecture graph (just depcruise-graph) as a side effect.

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorCode refactoring

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions