depdog check — github.com/matterpale/depdog
✗ core: allow [std] (2 violations)
github.com/matterpale/depdog/internal/core
→ github.com/matterpale/depdog/internal/report internal/core/evaluate.go:9
→ github.com/charmbracelet/lipgloss internal/core/core.go:12
2 violations · 10 packages · 107 edges checked in 112ms
depdog is a dependency watchdog: architecture rules — "the domain imports
nothing but the standard library," "handlers never import repositories" —
usually live in someone's head or a wiki, and they rot. depdog makes them
executable: you declare which components exist and who may import whom in one
small depdog.yaml, and depdog check enforces it against every import edge in
your module, exiting non-zero for CI.
Install
go install github.com/matterpale/depdog/cmd/depdog@latest
Prebuilt binaries for Linux, macOS, and Windows are on the
releases page; building from
source (go build -o depdog ./cmd/depdog) needs Go 1.26+.
Quick start
depdog init # scan the module and write a starter depdog.yaml
depdog check # enforce the rules; exit 1 on violations
init inspects your layout, matches it against an architecture preset, and
proposes a component mapping you refine interactively — drop, rename, or
re-pattern components — or accept as-is with --yes. It refuses to touch an
existing depdog.yaml; as the code grows, depdog init --merge rescans the
module and appends a component (and, under default: deny, a starter rule)
for every directory no existing pattern covers — editing the file in place
without disturbing your comments, ordering or formatting. When everything is
covered it changes nothing and says so.
Configuration
depdog.yaml lives at the repo root, next to go.mod:
version: 2
# Each component lists its path glob(s) and, inline, who it may import.
components:
main: { path: "cmd/**" } # no rule → open (the default)
domain: { path: "internal/domain/**", allow: [std] } # whitelist: std only
handler: { path: "internal/handler/**", deny: [service, repository] } # forbids its peers
service: { path: "internal/service/**", deny: [handler, repository] }
repository: { path: "internal/repository/**", deny: [handler, service] }
default: allow # fallback for a rule-less component (like main); the default if omitted
options:
test_files: hybrid # default; also: same-rules, relaxed
skip: ["internal/legacy/**"] # package dirs excluded from analysis
Here domain is a whitelist (an allow list — only what's listed passes) and the
three peers are blacklists (a deny list — everything except what's listed); the
stance is read per component from which word you use. main has no rule at all, so it
falls back to the top-level default — which is allow, so it may import anything
(an explicit allow: ["*"] would be equivalent, just noisier). path takes a single
glob or a list (path: ["internal/api/**", "internal/rpc/**"]).
An editor JSON Schema ships at
schema/depdog.schema.json for autocomplete and
validation (a test keeps it in lockstep with the parser).
Components and matching
A component is a named set of packages: each path glob is matched, recursive
doublestar style, against module-relative package directories. When patterns
overlap, the most specific one wins; equal specificity is an ambiguity error,
not a silent pick.
What goes in allow and deny
| Entry |
Matches |
domain, handler, … |
another component, by name |
std |
the Go standard library |
external |
any module that isn't yours |
unassigned |
in-module packages no component claims |
"*" |
everything |
golang.org/x/sync |
one specific external module, by prefix — any entry with / or . |
Groups name a reusable set of components: declare groups: { inner: [domain, core] }, then reference inner in any allow/deny list; it expands
to its members when the config loads.
Two rules of precedence to remember: an explicit deny always beats an
allow, and a component with neither falls back to the top-level default —
set default: deny to make unruled components fail closed (init asks which
stance you want).
Signals that never fail the build
Three findings are always reported but never exit non-zero on their own —
visibility without blocking adoption:
- Unmapped packages. In-module packages no component claims are warnings;
unmapped packages are how rule sets rot, so they stay visible.
- Dead patterns. A component whose patterns match no package is flagged —
a likely typo.
- Component cycles.
a ↔ b at the architecture level (which a
package-level compile check can't even have) is detected and reported as an
advisory.
Test files
test_files: hybrid (the default) lets _test.go files import any external
module while still enforcing component-to-component rules; same-rules is
strict, relaxed exempts test files entirely.
Commands
| Command |
What it does |
depdog init |
Scan the module and write a starter depdog.yaml; --merge extends an existing one in place |
depdog check [packages] |
Evaluate every import edge against the rules |
depdog baseline |
Record current violations to depdog.baseline.yaml for the ratchet |
depdog graph |
Emit the dependency graph as DOT or Mermaid |
depdog explain <component-or-package> [import] |
Explain why something is red (the rule that fired, with file:line), how a component is constrained, or whether A may import B and which rule decides it |
depdog config |
Print the compiled rule set — components, patterns, inferred stances, options — for debugging a config |
depdog tui (or bare depdog) |
Interactive terminal UI: component dashboard, browsable violations, per-package imports and importers |
All flags
| Command |
Flags |
init |
--preset ddd|hexagonal|layered|flat · --default deny|allow · --yes (non-interactive) · --force (overwrite) · --merge (extend an existing file, preserving comments and formatting) |
check |
--format text|json|github|sarif · --fail-on any|new · --color auto|always|never |
graph |
--format dot|mermaid · --level component|package · --violations-only · --focus <component> |
In the TUI, the Violations and Packages lists scroll and filter with
/; e opens the selection in $EDITOR at its file:line,
r re-runs the check in place, and ? shows all keys.
Exit codes are a contract:
| Code |
Meaning |
0 |
clean |
1 |
violations |
2 |
configuration or usage error |
CI
depdog check is CI-ready as-is. For inline pull-request annotations use the
GitHub format; for GitHub code scanning, emit SARIF:
- run: go run github.com/matterpale/depdog/cmd/depdog check --format github
# or, for the code-scanning tab:
- run: go run github.com/matterpale/depdog/cmd/depdog check --format sarif > depdog.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: depdog.sarif }
Adopting rules on a codebase that doesn't pass yet
Record today's violations as a baseline, then fail only on new ones — and shrink
the baseline over time:
depdog baseline # writes depdog.baseline.yaml
depdog check --fail-on new # exits 1 only on violations not in the baseline
depdog checks itself
depdog's own architecture is declared in its depdog.yaml and
enforced in CI: the language-agnostic engine (internal/core) depends on the
standard library only, language knowledge lives behind an adapter interface,
and the layers above may only import inward. A failing architecture is a
failing build.
Limitations
- One build configuration. depdog loads packages for the host's
GOOS/GOARCH and default build tags. Imports guarded by other build
constraints (e.g. //go:build windows on a non-Windows machine) aren't seen.
- Single module. Go workspaces (
go.work) aren't supported: depdog checks
one module and declines to run inside a workspace with a clear message (set
GOWORK=off to bypass a workspace and check the module directly).
Status
v0.2.0 — the current release. It brings the config v2 format (per-component
allow/deny in one block, default stance) among a round of post-v0.1
refinements. The M0–M5 roadmap in PLAN.md is complete;
BACKLOG.md tracks what's next.
License
MIT
🐕 woof.