Skip to content

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.

Create a file at .forge/plugins/<check-name>.yaml:

id: string # Unique identifier (used in output and overrides)
name: string # Human-readable name
description: string # What this check enforces and why
severity: P0 | P1 | P2 | P3
language: typescript | javascript | python | rust | go | any
pattern: string # ast-grep pattern to match
include: [string] # Glob patterns — only check matching files
exclude: [string] # Glob patterns — skip matching files
message: string # Message shown when the pattern matches (violation found)

Catch console.log calls left in production code:

.forge/plugins/no-console-log.yaml
id: no-console-log
name: No console.log in production code
description: >
console.log calls are development artifacts. They leak implementation
details in production logs and should be replaced with a structured logger.
severity: P1
language: typescript
pattern: "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)

Enforce TypeScript type safety:

.forge/plugins/no-any-type.yaml
id: no-any-type
name: No explicit any type
description: >
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: P1
language: typescript
pattern: ": 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:

.forge/plugins/no-direct-db-access.yaml
id: no-direct-db-access
name: No direct database access outside repository layer
description: >
Direct database calls (db.query, prisma.*, knex.*) must be confined to
the repository layer (src/repositories/**). Services and controllers must
call repositories instead.
severity: P0
language: typescript
pattern: "prisma.$METHOD($$$)"
include:
- "src/**/*.ts"
exclude:
- "src/repositories/**"
- "src/db/**"
- "**/*.test.ts"
message: "Direct Prisma call outside repository layer — move to src/repositories/"

Libraries that call process.exit break consumers unexpectedly:

.forge/plugins/no-process-exit.yaml
id: no-process-exit
name: No process.exit in library code
description: >
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: P1
language: javascript
pattern: "process.exit($CODE)"
include:
- "packages/**/*.js"
- "packages/**/*.ts"
exclude:
- "packages/**/bin/**"
- "packages/**/scripts/**"
message: "process.exit() in library code — throw an Error instead"

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.yaml

Or 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"

After creating a plugin and registering it, run:

Terminal window
forge health

If 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:

Terminal window
forge health --plugin .forge/plugins/no-console-log.yaml

This runs only the specified plugin’s check, not Forge’s full suite. Useful when iterating on the pattern.

Plugin patterns use ast-grep metavariable syntax:

SyntaxMeaning
$NAMEMatches any single AST node, binds to $NAME
$$$ARGSMatches 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.

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.