daml-lint 0.1.0

Static analysis scanner for Daml smart contracts
Documentation

daml-lint

CI License: AGPL v3

[!WARNING] This software is experimental and not intended for production use. Use at your own risk.

Static analysis scanner for Daml smart contracts. Catches security vulnerabilities and anti-patterns through AST pattern matching, similar to what Slither does for Solidity.

Part of the daml-tools workspace. Parsing is the shared daml-parser crate — lexer (comments, strings, layout-aware spans) → Haskell offside-rule layout resolution → recursive-descent parser producing a typed AST with positions on every node. daml-lint lowers that AST to a rule-facing IR and runs detectors over it. Files that fail to parse degrade to partial structure with a diagnostic on stderr (file:line:col); a scan never aborts on bad input.

Detectors

Detector Severity Description
missing-ensure-decimal HIGH Template has Decimal fields without an ensure clause bounding them to > 0
unguarded-division HIGH Division operation without a prior guard checking the denominator is non-zero
missing-positive-amount HIGH Choice accepts amount/quantity/price parameter without asserting it is positive
archive-before-execute HIGH Contract archived before a try/catch block — contract is lost if execution fails
head-of-list-query MEDIUM Pattern match on head of queryFilter result — non-deterministic ordering risk
unbounded-fields MEDIUM Text, List, or TextMap fields without size bounds in the ensure clause

Installation

Requires Rust 1.87+ (its rquickjs dependency needs rustc 1.87).

cargo install daml-lint

Or straight from the workspace repo:

cargo install --git https://github.com/stevennevins/daml-tools daml-lint

Or from a local checkout:

git clone https://github.com/stevennevins/daml-tools.git
cd daml-tools
cargo install --path crates/daml-lint

Library features

The default features build the published CLI and custom-rule engine:

[dependencies]
daml-lint = "0.1"

Library consumers that only need the built-in detectors and rule-facing IR can avoid the CLI parser and QuickJS runtime:

[dependencies]
daml-lint = { version = "0.1", default-features = false }

Enable custom-rules only when you need JavaScript AST rules from library code. The cli feature exists for the daml-lint binary.

Usage

Scan a single file:

daml-lint src/MyContract.daml

Scan a directory recursively:

daml-lint ./daml/

Choose an output format:

daml-lint ./daml/ --format sarif    # SARIF JSON (GitHub / IDE integration)
daml-lint ./daml/ --format markdown # Human-readable (default)
daml-lint ./daml/ --format json     # Machine-readable JSON

Write results to a file:

daml-lint ./daml/ --format sarif --output report.sarif

Custom detectors

Define your own detectors as AST rule scripts and pass them with --rules (repeatable), in the style of solhint custom rules:

daml-lint ./daml/ --rules my-rule.js --rules another-rule.js

A rule is TypeScript/JavaScript (executed by an embedded QuickJS engine): constants for metadata, plus visitor functions named after the node types you care about — like solhint's ContractDefinition(node) callbacks. Write rules in TypeScript against examples/daml-lint.d.ts for type checking and autocomplete:

const NAME = "template-requires-ensure";
const SEVERITY = "medium";
const DESCRIPTION = "Every template must declare an ensure clause";   // optional

function on_template(template: Template): void {
  if (template.ensure_clause === null) {
    report(template, `Template '${template.name}' has no ensure clause`);
  }
}

then compile to the JavaScript file you pass to --rules:

npx esbuild my-rule.ts --outfile=my-rule.js   # or tsc

(Plain JavaScript rules work directly — the compile step is only for TypeScript.)

Visitors (define any subset, at least one):

Function Called for Node fields
on_template(template) each template name, fields, signatory_exprs, observer_exprs, ensure_clause (null if absent), key_expr, key_type, maintainer_exprs, choices, interface_instances, span
on_choice(choice, template) each choice name, consuming, controller_exprs, observer_exprs, parameters, return_type, body, span
on_field(field, template) each template field name, type_, span
on_function(function) each top-level function name, type_signature, body, span
on_import(import) each import module_name, qualified, alias
on_interface(interface) each interface name, requires, viewtype, methods, choices, span
check(m) once per module ir_version, name, file, imports, templates, interfaces, functions, source

Report findings with report(node, message) (location taken from the node's span) or report(line, message). The rule's SEVERITY applies to all its findings. Node shapes are declared in examples/daml-lint.d.ts and mirror the IR in src/ir.rs; statement nodes in body are objects keyed by kind, e.g. "Create" in stmt.

Statements carry a typed expression AST: stmt.Let.value, stmt.Assert.condition_expr, stmt.Exercise.cid/.argument, and stmt.Other.expr are Expr nodes — tagged unions like { BinOp: { op: "/", lhs, rhs, span } } with a 1-based span on every node (see the Expr type in the .d.ts). Type-bearing fields carry TypeNode trees such as { Con: { name: "Party", qualifier: null, span } } and { App: { head, args, span } }; type spans include line/column, JavaScript string offsets (start/end, suitable for m.source.slice(start, end)), and parser byte offsets (byte_start/byte_end). Compatibility-only raw-text fields and rendered party-name lists were removed in the breaking custom-rule surface, so rules should match on structure, not substrings. examples/unguarded-division-ast.ts shows a denominator-guard check written entirely on typed nodes.

Removed v1/v2 compatibility fields and their structured replacements:

Removed field Use instead Notes
choice.body_raw, function.body_raw body (Statement[]) Match statements structurally; only stmt.Other.raw / Expr.Unknown.raw preserve unsupported source text.
template.ensure_clause.raw_text ensure_clause.expr (Expr) Match the condition structurally.
stmt.Let.expr stmt.Let.value (Expr) The bound expression.
stmt.Assert.condition stmt.Assert.condition_expr (Expr) The condition expression only.
stmt.Fetch.cid_expr, stmt.Archive.cid_expr, stmt.Exercise.cid_expr .cid (Expr) The contract-id expression.
stmt.Create.raw template_name + argument (Expr) argument is the created payload.
stmt.Exercise.raw cid + choice_name + argument (Expr) argument is the choice argument, if present.
choice.controllers controller_exprs (Expr[]) Flatten list expressions in the rule if you want list-literal party semantics.
template.signatories, template.observers signatory_exprs, observer_exprs (Expr[]) Structured party expressions only.

stmt.Other.raw and the Unknown expression's raw are deliberate raw-source escape hatches for constructs with no structured form (e.g. examples/no-trace.ts matches source text).

Heads up: visitors must be function declarations — arrow functions assigned to const are not discovered. If a script fails at runtime, the CLI exits 2; library callers can use Detector::try_detect to receive the rule error without terminating the host process. Rule errors are never swallowed. A runaway loop is interrupted so a broken rule can't hang CI. The engine runs JavaScript (ES2023) — no Node APIs, no require/import, no filesystem or network. Each rule's script is evaluated once and its visitors are then called for every module — visitors should be stateless; don't accumulate findings in top-level mutable state across files.

SEVERITY is one of critical, high, medium, low, info. Custom rules run alongside the built-in detectors, appear in all output formats, and count toward --fail-on. Rule names must not collide with built-in detector names or each other.

Examples:

Each example ships with its compiled .js next to it — that's the file --rules takes.

To check that a rule script parses without running a scan, point the tool at a nonexistent path — rule errors are reported before file discovery. (A valid script then prints No .daml files found., which also exits 2 — go by the message, not the exit code.)

CI gating

Use --fail-on to control when the tool returns a non-zero exit code:

daml-lint ./daml/ --fail-on medium   # fail on medium or above
daml-lint ./daml/ --fail-on critical # fail only on critical

Output Formats

  • SARIF — Standard format for static analysis tools. Integrates with GitHub Code Scanning and IDEs.
  • Markdown — Human-readable report grouped by severity. Good for pull request comments.
  • JSON — Flat findings array with summary counts. Good for dashboards and aggregation.

Exit Codes

Code Meaning
0 No findings at or above the --fail-on threshold
1 One or more findings at or above the threshold
2 CLI error (invalid format, no files found, etc.)
3 A scanned file had parse errors (scan is not authoritative)

Development

cargo test

Tests run entirely offline: parser and layout integration tests use a vendored copy of the daml-finance sources under corpus/daml-finance/ (634 real .daml files) — shared at the workspace root with daml-parser — as a ground-truth corpus; see corpus/daml-finance/README.md for provenance and licensing.

Public API Stability

daml-lint is pre-1.0. The CLI exit codes and documented feature flags are the stable user contract for 0.1.x. The rule-facing IR is intentionally public for custom rules and library users, but it may gain structure in 0.x minor releases; custom rules should check ir_version and match typed nodes rather than raw source substrings. Detector result types such as Finding, Severity, and DetectError are non-exhaustive; use their documented fields/accessors and keep wildcard arms when matching enums. Patch releases should remain compatible.

License

AGPL-3.0-only. See LICENSE.