Navigating the layered architecture

SkillFiles & storage

This skill should be used when deciding which layer or module a new file, function, or directory belongs in, or when a layer-boundary check fails. It owns the placement decision (which layer/module) and the import-direction rules — not the end-to-end recipe for wiring an operation through the API ladder (that is adding-operations). Trigger phrases include "where should this file/function go", "which layer is X in", "can topology import from sketching", "layer boundary violation", "check:boundaries failed", "VIOLATION: src/... imports from", "Direct .oc access is banned", "method calls on .wrapped are banned", or adding a new src/ directory or module.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Navigating the layered architecture skill

What this skill tells your AI

The instructions your AI receives, as published by andymai/brepjs in .claude/skills/architecture-navigation/SKILL.md and read by ahel’s review.

brepjs enforces a four-layer architecture where imports flow downward or sideways, never upward. The enforcement script is the source of truth for layer membership — CLAUDE.md and docs/architecture.md summaries lag behind it.

Layer table (source of truth: scripts/check-layer-boundaries.sh)

LayerDirectoriesMay import from
0kernel/, utils/nothing internal (same-layer only)
1core/layer 0
2topology/, 2d/, operations/, query/, measurement/, io/, worker/, csg/, voxel/, implicit/layers 0–1 + each other
3sketching/, text/, projection/, gear/, ns/, lattice/layers 0–2 + each other

Rules and caveats:

  • The rule is target_layer <= source_layer (the if (( target_layer > src_layer )) check in scripts/check-layer-boundaries.sh). Same-layer imports are always legal — e.g. src/topology/wrapperFns.ts imports @/operations/api.js and @/measurement/measureFns.js (both Layer 2).
  • Files at the src/ root (index.ts, quick.ts, sub-path entries like topology.ts, core.ts, result.ts) are exempt — the script assigns them layer -1 and skips them. They exist to re-export everything.
  • The script resolves both @/ alias and relative imports, but only greps from '...' statements — dynamic import() expressions without from are not checked. Do not rely on that gap.
  • When adding a new top-level src/ directory, add it to the case statement in the get_layer() function in scripts/check-layer-boundaries.sh and to the error-message layer listing at the bottom of the same script, or its imports go entirely unchecked.

Decision procedure: where does new code belong?

Work through this list in order; stop at the first match.

  1. A raw kernel/OCCT API call (anything touching oc.* or WASM objects) → a *Ops.ts file under src/kernel/occt/ (or src/kernel/brepkit/), exposed as a method on KernelAdapter in src/kernel/types.ts. Follow the /new-kernel-method command in .claude/commands/. See the kernel-abstraction skill.
  2. A pure helper with no geometry dependency (string/array/math utilities) → src/utils/.
  3. Shared types, Result, vectors, memory/disposal, branded shape typessrc/core/ (e.g. src/core/result.ts, src/core/shapeTypes.ts, src/core/disposal.ts).
  4. A shape operation (transform, boolean, modifier, query, measurement, I/O) → the matching Layer 2 module's *Fns.ts file, then climb the API ladder below. See the adding-operations skill for the full recipe.
  5. High-level sugar composing Layer 2 operations (sketch DSL, text, projection, gear generators, namespace re-exports) → the matching Layer 3 module.
  6. In doubt between two layers → put it in the lower layer that has everything it needs. Code can always be re-exported upward; it can never import upward.

The API ladder (Layer 2 shape operations)

New functionality goes in *Fns.ts first, then surfaces upward. The full chain (mermaid diagram in src/topology/README.md):

shape() fluent facade (wrapperFns.ts)
  → api.ts (functional public API)
    → *Fns.ts implementations
      → cast.ts / getKernel()

Checklist when adding an operation:

  1. Implement in the module's *Fns.ts (e.g. src/topology/booleanFns.ts), taking/returning branded types from src/core/shapeTypes.ts, returning Result<T, E> for fallible operations (see the result-error-handling skill).

  2. Add a short-named wrapper in src/topology/api.ts that accepts Shapeable<T> and delegates via resolve() (both from src/topology/apiTypes.ts), using an options object for optional parameters. Pattern:

    export function translate<T extends AnyShape<Dimension>>(shape: Shapeable<T>, v: Vec3): T {
      return transforms.translate(resolve(shape), v);
    }
    
  3. Optionally add a chainable method on Wrapped<T> in src/topology/wrapperFns.ts — it must delegate to api.ts, never contain its own implementation.

  4. Export from src/index.ts (organized by layer with // ── Layer N ── banners) and the matching sub-path entry at src/ root (topology.ts, operations.ts, etc. — table in docs/codebase-map.md).

  5. Update the module's README.md if one exists (pre-commit prints a non-blocking reminder via scripts/check-readme-reminders.sh).

Import and naming rules

RuleEnforced by
Imports flow downward/same-layer onlycheck:boundaries (pre-commit, CI, npm run validate)
Cross-directory imports use @/ alias (@/kernel/index.js); same-directory imports stay relative (./foo.js)convention only
All .ts imports use .js extensions (ESM; Vite transforms at build time)convention only — typecheck passes without it, so review for it explicitly
camelCase filenames (no PascalCase, no kebab-case)convention only
import type for type-only importsESLint consistent-type-imports

The @/ alias maps to ./src/* via tsconfig.json paths and vite.config.ts resolve.alias.

The kernel-abstraction rule

Layer 2+ code treats shapes as opaque handles. Read shape.wrapped only to pass it into a kernel method — never call methods on it, and never touch .oc:

// Correct: .wrapped passed as an argument
const volume = getKernel().volume(shape.wrapped);

// Banned: method called ON .wrapped
const hash = shape.wrapped.HashCode(1000);

ESLint enforces both bans via the no-restricted-syntax rule in the "Kernel abstraction boundary" config block in eslint.config.js, but only for ten enumerated directories: topology/, operations/, measurement/, query/, io/, 2d/, sketching/, projection/, text/, worker/. The rule applies just as much in csg/, voxel/, implicit/, gear/, ns/, and lattice/ — lint simply does not cover them yet, so apply it by discipline there (and add new directories to the ESLint file list when creating them).

If an operation needs a kernel capability that KernelAdapter lacks, the fix is never to reach through .wrapped — add the method to the adapter first (kernel-abstraction skill, /new-kernel-method command).

Fixing violations

SymptomCauseFix
VIOLATION: src/operations/foo.ts (layer 2: operations) imports from '@/sketching/draw.js' (layer 3: sketching) from check:boundariesLower layer imports a higher oneOne of: (a) move the importing code up to the higher layer, (b) extract the shared logic down into a layer both can import (usually core/ or a Layer 2 sibling), (c) invert the dependency — have the higher layer pass data/callbacks down
Direct .oc access is banned in Layer 2+ code (ESLint)Raw OCCT call outside kernel/Move the call into a kernel *Ops.ts file behind a KernelAdapter method, then call getKernel().method(...)
Direct method calls on .wrapped are banned in Layer 2+ code (ESLint)Treating a shape handle as an object with behaviorFind (or add) the equivalent KernelAdapter method in src/kernel/types.ts and call it via getKernel()
Boundary check passes locally but fails in CIPre-commit runs only the staged variant (check:boundaries:staged, .husky/pre-commit Tier 1); CI's quality job runs the full treeRun npm run check:boundaries (full) before pushing
New directory's imports never flaggedDirectory missing from the script's case statement (layer -1 is skipped)Register it in scripts/check-layer-boundaries.sh

Reproduce locally:

npm run check:boundaries          # full tree
npm run check:boundaries:staged   # staged files only (what pre-commit runs)
npx eslint src/ --quiet           # .oc / .wrapped bans
npm run validate                  # typecheck + lint + boundaries + format + changed tests

Where each gate runs: pre-commit Tier 1 (staged boundaries, parallel with lint-staged and typecheck), CI quality job in .github/workflows/ci.yml (full boundaries + check:patterns + knip), and scripts/validate-change.sh via npm run validate. See the quality-gates skill for the full gate matrix.

Additional resources

  • docs/architecture.md — layer diagrams, data flow, key patterns with correct/banned .wrapped examples (layer lists predate csg/voxel/implicit/gear/ns/lattice; trust the script).
  • docs/codebase-map.md — sub-path entry-point table and module→key-file maps.
  • docs/which-api.md — choosing between the fluent wrapper, Sketcher, functional API, and Drawing as a consumer.
  • CONTRIBUTING.md — "Layer Boundaries" and "ESM Imports" sections, including the boundary-error walkthrough.
  • Module READMEs exist for 2d, core, io, kernel, kernel/manifold, measurement, operations, projection, query, sketching, text, topology, utils — read the target module's README before adding code there. (worker, csg, voxel, implicit, gear, ns, lattice have none yet.)
  • ADRs for the "why": docs/decisions/0001-layered-architecture.md, 0002-kernel-abstraction.md, 0006-domain-boundaries.md, 0007-kernel-interface-segregation.md.
  • Sibling skills: adding-operations (end-to-end operation recipe), kernel-abstraction (adapter methods, getKernel/withKernel), result-error-handling (Result<T,E> conventions), quality-gates (all local/CI checks).

Signals

GitHub stars
102
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
architecture-navigation
Source
github.com/andymai/brepjs