.dependency-cruiser.cjs8.9 KBView on GitHub
/**
 * Dependency Cruiser configuration
 *
 * Enforces Cedar's layered architecture (from AGENTS.md):
 *
 *   db / env
 *       ↓
 *   services/*          (domain logic — must not reach up)
 *       ↓
 *   mastra/tools/*      (tool wrappers — must not reach up)
 *       ↓
 *   mastra/skills/*     (skill definitions)
 *       ↓
 *   mastra/agents/* + mastra/workflows/*  (orchestration)
 *
 * Rules are intentionally broad so that NEW violations are caught
 * automatically without needing a specific rule for each bad path.
 *
 * @type {import('dependency-cruiser').IConfiguration}
 */
module.exports = {
  forbidden: [
    // =========================================================================
    // BROAD ARCHITECTURAL RULES
    // These encode the layer direction from AGENTS.md and catch any new
    // violation without requiring a specific rule per bad path.
    // =========================================================================

    {
      name: 'services-cannot-import-mastra-skills-index',
      severity: 'error',
      comment:
        'Services sit below skills in the dependency stack. A static import of ' +
        'mastra/skills/index creates a CJS circular dep at server boot: ' +
        'skills/index → any skill → tools → agent-action-queue → <service> → skills/index. ' +
        'This causes skill exports to be void 0, breaking getSkill() at runtime. ' +
        'Use dynamic import() inside async functions instead (see automations.ts, draft-simulation.ts).',
      from: {
        path: '^(apps/server/)?src/services/',
      },
      to: {
        path: '^(apps/server/)?src/mastra/skills/index(\\.ts)?$',
        dependencyTypes: ['require', 'import', 'export'],
      },
    },


    {
      name: 'services-cannot-import-mastra-route-handlers',
      severity: 'error',
      comment:
        'Same layering rule as skills/index above, for the edge that actually broke the ' +
        'server on 2026-08-05: a static services -> mastra/routeHandlers import closed a ' +
        'cycle back into skills/event-execution/conversation-action-management, whose ' +
        'module body reads updateConversationFieldsTool + the task tools. Every runtime ' +
        'entry point then died on import with "Cannot access \'updateConversationFieldsTool\' ' +
        'before initialization". Route handlers pull in workflows -> agents -> mastra/index, ' +
        'so they drag essentially the whole graph in behind them. ' +
        'Use a dynamic import() inside the async call site (see dispatch-re-derivation.ts, ' +
        'move-event.ts, replay-execution.ts).',
      from: {
        path: '^(apps/server/)?src/services/',
      },
      to: {
        path: '^(apps/server/)?src/mastra/routeHandlers/',
        dependencyTypes: ['require', 'import', 'export'],
      },
    },

    // =========================================================================
    // SPECIFIC RULES (kept for documentation of known-bad patterns)
    // The broad rules above subsume these, but keeping them provides clearer
    // error messages for the most common violations.
    // =========================================================================

    // =========================================================================
    // PERMISSION AUTHORITY
    // =========================================================================

    {
      name: 'role-predicates-stay-inside-the-auth-authority',
      severity: 'error',
      comment:
        'WHAT THIS PREVENTS: a second answer to "may this caller act on this data?". ' +
        'services/auth/authorize.ts is the single permission authority, and it deliberately ' +
        'returns a VERDICT rather than a role, so that no call site can branch on one. That ' +
        'guarantee only holds while the role PREDICATES it replaced stay unreachable: ' +
        'isOrgAdminRole / resolveOrgMembership / userIsOrgAdmin ' +
        '(services/organizations/org-admin.ts). A fourth, canActOnTarget ' +
        '(services/auth/org-access.ts), was deleted outright once it had no importers left. ' +
        'Before ' +
        'the authority existed those were imported by eleven call sites that each re-derived ' +
        'the rule, one of which computed it on the wrong user, and four caller-supplied ' +
        'identifiers reached another user\'s data below the gate as a result. Importing a ' +
        'predicate is how a twelfth appears. Call authorize() instead, at the point the ' +
        'EFFECTIVE TARGET resolves. ' +
        'ONE CARVE-OUT, and it used to be two. trpc/routes/org-admin.ts owns ' +
        'assertOrgAdmin, the tRPC layer\'s org-admin gate, and reads isOrgAdminRole for it; ' +
        'that file is on the matching allowlist in ' +
        'services/auth/__tests__/role-reads-confined-to-authority.test.ts, and the two ' +
        'allowlists are meant to be read together. ' +
        'THE CARVE-OUT THAT WAS REMOVED, and why. services/auth/** was exempt as the ' +
        'authority itself, which sounded right and made the rule LAUNDERABLE: a two-line ' +
        '`export { isOrgAdminRole } from \'../organizations/org-admin\'` placed anywhere ' +
        'under services/auth/ satisfied this rule as a `from`, re-published the predicate ' +
        'under a path every module may import, and was invisible to the source scan in ' +
        'role-reads-confined-to-authority.test.ts because neither file contains the string ' +
        '`organizationRole`. The exemption bought nothing: authorize.ts has its own private ' +
        'isAdminRole and no file under services/auth/ imports this module. So the authority ' +
        'is now fenced off from the predicates it replaced like everyone else, and there is ' +
        'no exempt path left to launder them through. ' +
        'NOT COVERED, on purpose: services/auth/org-role.ts (isOrgViewer). The read-only ' +
        'viewer seat is an orthogonal DENY axis that answers only about the caller, never ' +
        'about a delegation target, and it is enforced at each transport (trpc.ts, the chat ' +
        'harness, table-stream-apply) by design. What keeps an `isOrgAdmin` from being added ' +
        'beside it by analogy is not this rule but the export-surface assertion in ' +
        'role-reads-confined-to-authority.test.ts.',
      from: {
        path: '^(apps/server/)?src/',
        pathNot: ['^(apps/server/)?src/trpc/routes/org-admin\\.ts$'],
      },
      to: {
        path: ['^(apps/server/)?src/services/organizations/org-admin(\\.ts)?$'],
        dependencyTypes: ['require', 'import', 'export'],
      },
    },

    {
      name: 'role-predicates-are-not-re-exported-from-the-carve-out',
      severity: 'error',
      comment:
        'WHAT THIS PREVENTS: the one remaining carve-out turning into a distributor. ' +
        'trpc/routes/org-admin.ts is allowed to IMPORT isOrgAdminRole because it owns ' +
        'assertOrgAdmin, the tRPC layer\'s org-admin gate. It is not allowed to RE-EXPORT ' +
        'it: `export { isOrgAdminRole } from \'../../services/organizations/org-admin\'` ' +
        'would make the predicate reachable from every module in the codebase through an ' +
        'exempt path, with `deps:check` green, which is the same laundering the ' +
        'services/auth/** exemption above allowed until it was removed. A carve-out is a ' +
        'permission to consume, never a permission to republish. ' +
        'This cannot catch an import bound to a local name and re-exported separately ' +
        '(`import { x }` then `export { x }`), which the module graph renders as an ' +
        'ordinary import. That residue is covered from the other side: ' +
        'role-reads-confined-to-authority.test.ts asserts what the auth surface EXPORTS, ' +
        'and any consumer of a laundered predicate still has to be a file that decides ' +
        'access on a role, which is the thing its DECISION count pins.',
      from: {
        path: '^(apps/server/)?src/trpc/routes/org-admin\\.ts$',
      },
      to: {
        path: ['^(apps/server/)?src/services/organizations/org-admin(\\.ts)?$'],
        dependencyTypes: ['export'],
      },
    },

    {
      name: 'provider-credential-service-does-not-import-mastra',
      severity: 'error',
      comment:
        'Provider credential resolution is shared by Mastra tools and must stay below Mastra.',
      from: {
        path: '^(apps/server/)?src/services/integrations/provider-credentials\\.ts$',
      },
      to: {
        path: '^(apps/server/)?src/mastra/',
      },
    },
  ],
  options: {
    doNotFollow: {
      // env.ts lazily wires AWS runtime bindings and reaches broad workflow surfaces.
      // Test files and scripts are excluded to keep the check fast and focused.
      path: 'node_modules|__tests__|__mocks__|\\.test\\.ts$|\\.spec\\.ts$|^(apps/server/)?src/env\\.ts$|^(apps/server/)?scripts/',
    },
    enhancedResolveOptions: {
      extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
    },
    tsConfig: {
      fileName: 'tsconfig.json',
    },
  },
};