Custom rules

Extend the built-in rule set with project-specific checks. Custom rules encode domain conventions, team standards, or patterns the built-in rules don't cover. Wiring them into a project is covered on Custom rule configuration.

Let an agent do it

Writing a rule is mostly matching an AST, and an agent can do it:

npx nestjs-doctor@latest --init

That installs a nestjs-doctor-create-rule skill. Describe the convention you want enforced and the agent checks static analysis can see it, picks file or project scope, writes the .ts file, points customRulesDir at it, and runs a scan to confirm the rule loads. It works in Claude Code, Cursor, Codex, and the other agents on Coding agents.

A rule it cannot detect statically is worth knowing about early. The skill says so instead of writing something that never fires.

The rest of this page is the same work by hand.

Rule shape

Every custom rule exports an object with two properties, meta (a descriptor) and check (the inspection function):

import type { RuleContext } from "nestjs-doctor";
 
export const myRule = {
  meta: {
    id: "my-rule-id",
    category: "correctness",
    severity: "warning",
    description: "Short explanation of what this rule checks",
    help: "Actionable advice on how to fix it.",
  },
  check(context: RuleContext) {
    // inspect context.sourceFile, call context.report() per diagnostic
  },
};

meta fields

FieldTypeRequiredDescription
idstringYesUnique identifier for the rule
categorystringYesOne of "security", "performance", "correctness", "architecture"
severitystringYesOne of "error", "warning", "info"
descriptionstringYesShort summary shown in the report
helpstringYesFix guidance shown alongside diagnostics
scopestringNo"file" (default) or "project"
tagsstring[]NoLabels stamped onto every diagnostic the rule emits
surfacesstring[]NoWhere the rule may appear, described on Surfaces

Custom rules come in the two code scopes, file and project. The schema scope the built-in schema rules use is not available to them, and "schema" is not a valid category.

Tags

The one tag with behavior today is "module-graph". Diagnostics carrying it appear in the Modules Graph tab's problems drawer, beside the built-in module-wiring ones:

meta: {
  id: "no-untagged-feature-module",
  category: "architecture",
  severity: "warning",
  description: "Feature modules must follow the naming convention",
  help: "Rename the module to end with FeatureModule.",
  tags: ["module-graph"],
},

A tags value that is not an array of strings is dropped with a warning, and the rule still runs.

The check function

The check function receives a RuleContext. The first three members are what most rules need, and the rest carry facts a single file cannot see:

MemberWhat it is
sourceFileThe ts-morph SourceFile for the file being analyzed
filePathThe absolute path to that file
report(diagnostic)Emits a diagnostic with filePath, message, help, line, and column
configThe resolved config for the project
diProvidersClass names NestJS instantiates itself
guardsComposed guard decorators, globally registered guards, and guarded base classes
moduleDirectoriesDirectories that hold a module file, for boundary checks

Every member after report is optional. Absent means the fact was not determined, which is not the same as empty.

For project-scoped rules (scope: "project"), the context carries the whole project instead of a single file:

MemberWhat it holds
projectThe ts-morph Project, every parsed file
filesPaths of the scanned files
moduleGraphModules, their imports and exports
providersEvery provider and the module that declares it
configThe resolved config for the project
targetPathThe directory being scanned, for reading a file the scan did not parse
installRootWhere to resolve node_modules from, when that is not targetPath

targetPath is what a rule uses to reach something outside the TypeScript it was given, such as the project's package.json. Read it when the rule runs rather than caching it: the language server keeps one context alive for the whole editing session.

installRoot is absent unless the two differ, which happens under --scope changed. That mode scans a base revision in a throwaway checkout with no install, so a rule reading node_modules should prefer it:

const root = context.installRoot ?? context.targetPath;

Reading targetPath there finds nothing installed. The rule then reports against the base revision what it would never report against the working tree.

Example

A rule that flags unresolved TODO comments:

import type { RuleContext } from "nestjs-doctor";
 
export const noTodoComments = {
  meta: {
    id: "no-todo-comments",
    category: "correctness",
    severity: "warning",
    description: "TODO comments should be resolved before merging",
    help: "Replace the TODO with an implementation or open an issue.",
  },
  check(context: RuleContext) {
    const text = context.sourceFile.getFullText();
    const regex = /\/\/\s*TODO/gi;
    let match: RegExpExecArray | null;
    while ((match = regex.exec(text)) !== null) {
      const pos = context.sourceFile.getLineAndColumnAtPos(match.index);
      context.report({
        message: "Unresolved TODO comment",
        help: "Replace the TODO with an implementation or open an issue.",
        filePath: context.filePath,
        line: pos.line,
        column: pos.column,
      });
    }
  },
};

The line and column come from ts-morph: getLineAndColumnAtPos turns a character offset into the 1-indexed position a diagnostic expects.

The Rule Lab tab

The HTML report has a Rule Lab tab for writing and testing custom rules in the browser:

npx nestjs-doctor . --report

Scaffold a rule with /nestjs-doctor-create-rule, test it in the Rule Lab, then save the .ts file to your customRulesDir.