shellcheck+prettier+git difffor dagu workflow YAML — local, deterministic, single binary.
dagulint lints, formats, and diffs dagu workflow YAML files
locally. It applies a 20-rule catalogue (DAGU001–DAGU020), produces canonical
formatting with atomic write-back and round-trip self-check, and reports
semantic diffs between two workflows. Single Rust binary, no network, no
telemetry, no plugins. Pre-commit and CI ready.
The dagu DSL is small but full of cross-step constraints (depends-on graphs,
executor shapes, working-dir inheritance, env precedence) that ordinary YAML
linters do not check. Live as of 2026-04-25 the dagu repo has 3,325 stars,
130 open issues, and active commits in the last week — but no public
linter, formatter, or differ exists outside dagu's own dagu start --dry-run,
which only catches a subset of basic syntax errors.
dagulint exists to fill that gap: a portable, single-binary checker that
plugs into pre-commit hooks, GitHub Actions, and PR review tooling without
spinning up a dagu server, without a Python dependency, and without network
access.
# from a clone
cargo install --path .
# from GitHub
cargo install --git https://github.com/JSLEEKR/dagulintThe binary is a single self-contained executable (~3 MB stripped release).
$ cat ci.yaml
name: ci
steps:
- name: build
command: echo building
- name: deploy
command: echo deploying ${REGION}
depends: build
$ dagulint lint ci.yaml
[DAGU007] ci.yaml: step 'deploy': references undefined param 'REGION' (see dagu#2032)
$ echo $?
1$ dagulint fmt ci.yaml > /tmp/ci-formatted.yaml
$ dagulint fmt -w ci.yaml # atomic in-place
$ dagulint fmt --check ci.yaml # exit 1 if reformatting neededfmt is idempotent: fmt -w; fmt -w is a guaranteed no-op. Every
fmt output is parsed back and IR-compared against the input — any
silent corruption (BOM, multi-doc, anchor reorder, dup-key collapse)
is rejected before the write.
$ dagulint diff ci.v1.yaml ci.v2.yaml
+ step: deploy
- step: old_deploy
~ depends(test): build
~ executor(build): shell -> dockerJSON-format diff is byte-identical across runs:
$ dagulint diff --format json a.yaml b.yaml
{"changes":[{"kind":"step_added","step":"deploy"}],"error":null}| ID | Severity | Rule |
|---|---|---|
| DAGU001 | medium | Unknown top-level key |
| DAGU002 | high | Step missing command/script/run/executor |
| DAGU003 | high | Depends-on cycle |
| DAGU004 | high | Depends-on references undefined step |
| DAGU005 | medium | Executor type unknown |
| DAGU006 | medium | workingDir inherited but not declared (dagu#1656) |
| DAGU007 | medium | Param eval shape mismatch (dagu#2032) |
| DAGU008 | medium | Container executor without command/script (dagu#967) |
| DAGU009 | medium | Schedule expression invalid |
| DAGU010 | low | Env-var name not all-caps |
| DAGU011 | medium | ${var} reference to undefined param |
| DAGU012 | medium | histRetentionDays type mismatch (dagu#1332) |
| DAGU013 | medium | Handler-on-failure references undefined step |
| DAGU014 | low | Dotnotation env precedence shadow |
| DAGU015 | medium | stdout/stderr file outside workingDir |
| DAGU016 | high | SubDAG reference path traversal (../) |
| DAGU017 | low | Secret pattern in literal (token=, sk-…) |
| DAGU018 | high | Duplicate step name within same DAG |
| DAGU019 | high | Oversize file (>5 MiB) — reject |
| DAGU020 | high | Tab in indent (YAML forbids tabs) |
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: dagulint
name: dagulint
entry: dagulint lint --fail-on=high
language: system
files: '\.ya?ml$'- name: Install dagulint
run: cargo install --git https://github.com/JSLEEKR/dagulint
- name: Lint workflows
run: dagulint lint --format=json --fail-on=high workflows/ || exit 1
- name: Check formatting
run: dagulint fmt --check workflows/main.yamlyamllint is a generic YAML linter — it
checks indentation, line length, quote style. It does not know the dagu
DSL: it cannot tell you that depends: nonexistent_step is broken, or that
your executor: martian is unrecognised, or that your depends-on graph has
a cycle. Use yamllint and dagulint together. They are complementary,
not competitive.
dagu's own dagu start --dry-run does basic syntax + reachability checks.
It does not ship a separate lint subcommand and does not produce the
full DAGU001–DAGU020 catalogue. It also requires the dagu binary to be
installed and on PATH. dagulint is intentionally out-of-tree, single-
binary, and DSL-aware — like gofmt is to go vet. If dagu lint
ships natively in dagu v2, dagulint's fmt and diff subcommands remain
unique.
gitleaks scans bytes for secret
leaks. dagulint's DAGU017 rule is a workflow-author-mistake warning —
"you wrote token=sk-abc123 literally instead of ${TOKEN}". Different
surface area: gitleaks tells you a secret has leaked into git; DAGU017
tells you a workflow author wrote one in the literal. Run both.
difyctl is our own Dify-DSL linter/differ/formatter shipped at R86 (2026-04-19). dagulint targets the dagu DSL — a different YAML shape, different rule catalogue, different workflow runtime. The architectural patterns (shared input validator, typed sentinels, atomic fmt with round-trip self-check) are deliberately reused because they're battle-tested across difyctl's 274 tests and 16 eval cycles.
dagulint enforces three architectural invariants at build time:
-
Single input validator.
parse::validate(bytes) -> Result<Workflow>is the SOLE entry point that every subcommand routes through. lint, fmt, and diff cannot drift in what they accept (R86 cross-command parity cascade lesson, applied at design time, not after a 4-cycle cascade). -
Typed sentinel errors. Every documented failure class has its own
LintErrorvariant. Tests usematches!(err, LintError::BomUtf16)patterns; a meta-test (tests/no_contains_in_error_matching.rs) greps the test directory and fails the build if any test matches error messages by substring (R86 N-cycle errors.Is lesson, Rust analogue). -
Round-trip self-check on every fmt write. Every
fmtoutput is parsed back and IR-compared against the input. Any silent corruption (BOM, multi-doc, anchor reorder, dup-key collapse) aborts the write with a typedRoundTriperror before the file is touched.
A 100-run byte-identity test on --format json outputs guards against
non-determinism (R78 sort_keys lesson). The cross-command parity audit
in tests/cross_command_parity.rs runs every hostile fixture through
all three subcommands and asserts uniform classification.
All --format json output is byte-identical across runs:
$ for i in {1..100}; do dagulint lint --format json wf.yaml > /tmp/out.$i; done
$ md5sum /tmp/out.* | uniq -c
100 <single hash>Tested at build time in tests/deterministic_json.rs.
- Auto-fix for lint findings is not implemented.
lintreports;fmtformats. They are separate concerns by design. - Plugin / custom-rule API is not in v1. Catalogue is fixed at 20 built-in rules. (v1.1 candidate.)
- LSP wrapper is not in v1. JSON output is the layer an IDE extension can consume. (v1.1 candidate.)
- Workflow runtime simulation is out of scope. dagu does that.
Bug reports, rule proposals, and dagu-version-range PRs are welcome.
cargo test # ~220+ tests
cargo clippy -- -D warnings # zero warnings
cargo fmt --check # canonical Rust styleMIT © 2026 JSLEEKR. See LICENSE.