Skip to content
Canopy is in pre-release. These docs describe the product at its public launch — commands, tool names, and integration examples reflect what you'll see once binaries ship. Join the waitlist →

Write a Custom Health Check

Canopy’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 .canopy/plugins/<pack-name>.yaml. Each file is a pack of one or more checks:

name: string # Pack name — appears in finding check_ids (plugin:<name>:<n>)
version: string # Informational semver, e.g. "1.0"
severity: P0 | P1 | P2 | info # Default for checks without their own (default: P1)
checks:
- type: file_exists | pattern_present | pattern_absent | file_count | script
severity: P0 | P1 | P2 | info # Optional per-check override
message: string # Emitted when the check fails
# ...plus the fields for that check type (see examples below)

The five check types:

TypeAssertsFields
file_existsListed files are present on diskfiles: [paths]
pattern_presentRegex matches at least one line in a filefile, pattern
pattern_absentRegex matches NO line in any matched fileglob, pattern, exclude (optional)
file_countDirectory contains at least N files with an extensiondirectory, extension, minimum
scriptShell command exits 0 (runs via /bin/sh -c)command

Catch console.log calls left in production code:

.canopy/plugins/no-console-log.yaml
name: no-console-log
version: "1.0"
severity: P1
checks:
- type: pattern_absent
message: "console.log found — use the structured logger (import { logger } from '@/lib/logger')"
glob: "src/**/*.ts"
pattern: 'console\.log\s*\('
exclude:
- ".test."
- ".spec."
- "/scripts/"

Each violating line becomes one finding. In canopy ci --format json output:

{
"check_id": "plugin:no-console-log:0",
"detail": "Forbidden pattern 'console\\.log\\s*\\(' found in 'src/payments/checkout.ts' at line 87",
"file_path": "src/payments/checkout.ts",
"line_number": 87,
"message": "console.log found — use the structured logger (import { logger } from '@/lib/logger')",
"severity": "error"
}

Enforce TypeScript type safety:

.canopy/plugins/no-any-type.yaml
name: no-any-type
version: "1.0"
checks:
- type: pattern_absent
severity: P1
message: "Explicit 'any' type found — use 'unknown' or a specific type"
glob: "src/**/*.ts"
pattern: ':\s*any\b'
exclude:
- ".d.ts"
- ".test."

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:

.canopy/plugins/no-direct-db-access.yaml
name: no-direct-db-access
version: "1.0"
checks:
- type: pattern_absent
severity: P0
message: "Direct Prisma call outside repository layer — move to src/repositories/"
glob: "src/**/*.ts"
pattern: 'prisma\.\w+\s*\('
exclude:
- "src/repositories/"
- "src/db/"
- ".test."

Combine check types in one pack — assert scaffolding exists alongside pattern rules:

.canopy/plugins/project-shape.yaml
name: project-shape
version: "1.0"
severity: P2
checks:
- type: file_exists
message: "Missing required config files"
files:
- package.json
- tsconfig.json
- type: file_count
message: "No tests under tests/ — add at least one *.test.ts"
directory: "tests"
extension: ".ts"
minimum: 1
- type: pattern_present
severity: P1
message: "package.json must pin the Node engine"
file: package.json
pattern: '"engines"'

Drop the YAML file in <repo>/.canopy/plugins/ and the health runtime picks it up automatically on the next canopy index or canopy ci run. There is nothing to register.

Canopy also ships first-party packs (React, Rust, Python, Supabase) embedded in the binary:

Terminal window
canopy plugins list # bundled packs + which are installed in this repo
canopy plugins install react # copies the pack into .canopy/plugins/react.yaml
canopy plugins disable react # removes it

Installed packs are plain YAML in your repo — edit the globs and severities to fit your layout.

After creating a plugin, run the health check and filter to your pack’s findings. On Pro tier:

Terminal window
canopy ci --format json | jq '.findings[] | select(.check_id | startswith("plugin:no-console-log"))'

Findings carry check_id: plugin:<name>:<index>, where <index> is the check’s position in the checks list. On Solo tier, run canopy index (health checks run during indexing) and review findings in the dashboard (canopy dashboard); they also surface through canopy_answer pre-edit triage (“what should I know before editing src/payments/checkout.ts?”).

When iterating on a pattern, keep the feedback loop tight by putting the check under test in its own pack file — each file in .canopy/plugins/ is independent, so you can move the others out of the directory temporarily and restore them when the pattern is right.

pattern fields are regular expressions (Rust regex syntax), evaluated line by line:

  • pattern_absent fails for every line the regex matches in any file the glob selects.
  • pattern_present passes when the regex matches at least one line of file.
  • Escape regex metacharacters in literal code: console\.log\s*\(, prisma\.\w+\(.
  • Globs use ** for any-depth matching (e.g. src/**/*.ts). One extension per check — write a second check for .tsx.
  • exclude entries are substring matches against the file path: ".test." skips test files, "src/repositories/" skips a directory.

A malformed YAML file or invalid regex never blocks the run — it is reported as a P0 finding for that plugin and the remaining plugins still execute.

Pattern never matches despite visible violations Patterns are matched one line at a time — a construct that spans multiple lines (e.g. a call broken across lines) won’t match a single-line regex. Anchor on the part that always sits on one line, such as the opening call: console\.log\s*\(.

Plugin fires on test files Add the test path fragment to exclude:

exclude:
- ".test."
- ".spec."
- "/__tests__/"

Severity too high for gradual adoption If introducing a new check on an existing codebase with many existing violations, start at info. Once the violations are addressed, promote to P1 or P0. This avoids blocking CI from day one (canopy ci --fail-on-p0 fails builds on P0 findings).