Skip to content

JSLEEKR/dagulint

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dagulint

Tests License Rust Stars

shellcheck + prettier + git diff for 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.

Why this exists

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.

Install

# from a clone
cargo install --path .

# from GitHub
cargo install --git https://github.com/JSLEEKR/dagulint

The binary is a single self-contained executable (~3 MB stripped release).

Quickstart

Lint

$ 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

Format

$ 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 needed

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

Diff

$ dagulint diff ci.v1.yaml ci.v2.yaml
+ step: deploy
- step: old_deploy
~ depends(test): build
~ executor(build): shell -> docker

JSON-format diff is byte-identical across runs:

$ dagulint diff --format json a.yaml b.yaml
{"changes":[{"kind":"step_added","step":"deploy"}],"error":null}

Rule catalogue

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)

CI recipe

Pre-commit

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: dagulint
        name: dagulint
        entry: dagulint lint --fail-on=high
        language: system
        files: '\.ya?ml$'

GitHub Actions

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

When to use dagulint vs alternatives

vs yamllint

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

vs dagu start --dry-run

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.

vs gitleaks

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.

vs JSLEEKR/difyctl: same shape, different DSL

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.

Architecture

dagulint enforces three architectural invariants at build time:

  1. 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).

  2. Typed sentinel errors. Every documented failure class has its own LintError variant. Tests use matches!(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).

  3. Round-trip self-check on every fmt write. Every fmt output 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 typed RoundTrip error 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.

Determinism

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.

Limitations (v1)

  • Auto-fix for lint findings is not implemented. lint reports; fmt formats. 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.

Contributing

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 style

License

MIT © 2026 JSLEEKR. See LICENSE.

About

Local-first lint, fmt, diff for dagu workflow YAML — DAGU001-020 rule catalogue, deterministic output, single binary.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages