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.
Plugin schema
Section titled “Plugin schema”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:
| Type | Asserts | Fields |
|---|---|---|
file_exists | Listed files are present on disk | files: [paths] |
pattern_present | Regex matches at least one line in a file | file, pattern |
pattern_absent | Regex matches NO line in any matched file | glob, pattern, exclude (optional) |
file_count | Directory contains at least N files with an extension | directory, extension, minimum |
script | Shell command exits 0 (runs via /bin/sh -c) | command |
Example: no console.log
Section titled “Example: no console.log”Catch console.log calls left in production code:
name: no-console-logversion: "1.0"severity: P1checks: - 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"}Example: no any type
Section titled “Example: no any type”Enforce TypeScript type safety:
name: no-any-typeversion: "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:
name: no-direct-db-accessversion: "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."Example: required project files
Section titled “Example: required project files”Combine check types in one pack — assert scaffolding exists alongside pattern rules:
name: project-shapeversion: "1.0"severity: P2checks: - 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"'Discovery — no registration step
Section titled “Discovery — no registration step”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:
canopy plugins list # bundled packs + which are installed in this repocanopy plugins install react # copies the pack into .canopy/plugins/react.yamlcanopy plugins disable react # removes itInstalled packs are plain YAML in your repo — edit the globs and severities to fit your layout.
Test a plugin
Section titled “Test a plugin”After creating a plugin, run the health check and filter to your pack’s findings. On Pro tier:
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 syntax
Section titled “Pattern syntax”pattern fields are regular expressions (Rust regex syntax), evaluated line by line:
pattern_absentfails for every line the regex matches in any file theglobselects.pattern_presentpasses when the regex matches at least one line offile.- 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. excludeentries 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.
Common pitfalls
Section titled “Common pitfalls”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).