Write a Custom Health Check
import { Aside, Tabs, TabItem } from ‘@astrojs/starlight/components’;
Forge’s built-in health checks cover structural issues common to all codebases: broken imports, dead exports, circular dependencies. Custom health check plugins let you enforce project-specific rules — “no console.log in production code,” “no any type in TypeScript,” “no direct database calls outside the repository layer.”
Plugins are YAML files. No code to compile, no binary to build.
Plugin schema
Section titled “Plugin schema”Create a file at .forge/plugins/<check-name>.yaml:
id: string # Unique identifier (used in output and overrides)name: string # Human-readable namedescription: string # What this check enforces and whyseverity: P0 | P1 | P2 | P3language: typescript | javascript | python | rust | go | anypattern: string # ast-grep pattern to matchinclude: [string] # Glob patterns — only check matching filesexclude: [string] # Glob patterns — skip matching filesmessage: string # Message shown when the pattern matches (violation found)Example: no console.log
Section titled “Example: no console.log”Catch console.log calls left in production code:
id: no-console-logname: No console.log in production codedescription: > console.log calls are development artifacts. They leak implementation details in production logs and should be replaced with a structured logger.severity: P1language: typescriptpattern: "console.log($$$)"include: - "src/**/*.ts" - "src/**/*.tsx"exclude: - "**/*.test.ts" - "**/*.spec.ts" - "**/scripts/**"message: "console.log found — use the structured logger (import { logger } from '@/lib/logger')"Violations in forge health output:
P1 [no-console-log] src/payments/checkout.ts:87 console.log found — use the structured logger (import { logger } from '@/lib/logger') Match: console.log("Processing payment for user:", userId)Example: no any type
Section titled “Example: no any type”Enforce TypeScript type safety:
id: no-any-typename: No explicit any typedescription: > Explicit 'any' types defeat TypeScript's type safety guarantees. Use 'unknown' for truly unknown values, or a proper type if the shape is known.severity: P1language: typescriptpattern: ": any"include: - "src/**/*.ts" - "src/**/*.tsx"exclude: - "**/*.d.ts" - "**/*.test.ts"message: "Explicit 'any' type found — use 'unknown' or a specific type"Example: no direct database access outside repositories
Section titled “Example: no direct database access outside repositories”Enforce layered architecture — only repository files should call the database directly:
id: no-direct-db-accessname: No direct database access outside repository layerdescription: > Direct database calls (db.query, prisma.*, knex.*) must be confined to the repository layer (src/repositories/**). Services and controllers must call repositories instead.severity: P0language: typescriptpattern: "prisma.$METHOD($$$)"include: - "src/**/*.ts"exclude: - "src/repositories/**" - "src/db/**" - "**/*.test.ts"message: "Direct Prisma call outside repository layer — move to src/repositories/"Example: no process.exit in library code
Section titled “Example: no process.exit in library code”Libraries that call process.exit break consumers unexpectedly:
id: no-process-exitname: No process.exit in library codedescription: > process.exit() in library code terminates the host process without warning. Throw an error instead and let the caller decide how to handle it.severity: P1language: javascriptpattern: "process.exit($CODE)"include: - "packages/**/*.js" - "packages/**/*.ts"exclude: - "packages/**/bin/**" - "packages/**/scripts/**"message: "process.exit() in library code — throw an Error instead"Register plugins
Section titled “Register plugins”Reference plugins in .forge/team.yml (for team-wide rules):
plugins: - path: .forge/plugins/no-console-log.yaml - path: .forge/plugins/no-any-type.yaml - path: .forge/plugins/no-direct-db-access.yaml - path: .forge/plugins/no-process-exit.yamlOr in .forge/config.toml (for personal or repo-local rules):
[[plugins]]path = ".forge/plugins/no-console-log.yaml"
[[plugins]]path = ".forge/plugins/no-any-type.yaml"Test a plugin
Section titled “Test a plugin”After creating a plugin and registering it, run:
forge healthIf the pattern matches any files in the include paths, violations appear in the output under the plugin’s id and severity.
To test a plugin in isolation:
forge health --plugin .forge/plugins/no-console-log.yamlThis runs only the specified plugin’s check, not Forge’s full suite. Useful when iterating on the pattern.
ast-grep pattern syntax
Section titled “ast-grep pattern syntax”Plugin patterns use ast-grep metavariable syntax:
| Syntax | Meaning |
|---|---|
$NAME | Matches any single AST node, binds to $NAME |
$$$ARGS | Matches zero or more nodes (variadic) |
$_ | Matches any single node, discards the match |
$$$_ | Matches zero or more nodes, discards the match |
Patterns must match the complete AST node — partial matches don’t trigger. Use $$$ to match function arguments of any arity.
Advanced: multi-node patterns
You can match across multiple statements using YAML multi-line syntax:
pattern: | if ($COND) { $$$BODY }This matches any if block regardless of body content. Use this to find patterns that span multiple lines.
For TypeScript specifically, you can match type annotations:
pattern: "const $VAR: any = $EXPR"This matches const foo: any = bar but not const foo = bar as any (which is a different AST shape). Write separate plugins for each syntactic form you want to catch.
Common pitfalls
Section titled “Common pitfalls”Pattern never matches despite visible violations
Check the AST shape. forge_parse_file returns the symbol tree for a file — examine how your target construct is represented. A pattern like console.log($ARG) only matches single-argument calls; use console.log($$$) to match any number of arguments.
Plugin fires on test files
Add the test file pattern to exclude:
exclude: - "**/*.test.ts" - "**/*.spec.ts" - "**/__tests__/**"Severity too high for gradual adoption
If introducing a new check on an existing codebase with many existing violations, start at P3 (info). Once the violations are addressed, promote to P1 or P0. This avoids blocking CI from day one.