.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',
},
},
};