# Lints

> Every structural and semantic check the analyzer runs on a workflow, and what each one is protecting you from.

Source: https://www.sendlang.com/docs/sendflow/lints
Section: SendFlow

`sendflow-lint` runs two kinds of check. **Structural lints** need no knowledge of
your account and always run. **Registry checks** need to know what attributes,
events, topics, templates and lists exist, and are skipped if you do not supply a
registry.

Errors fail the build. Warnings do not — they are things that are probably wrong,
and occasionally exactly right.

## Structural — errors

These are all caught with no registry at all.

| Message | What it protects you from |
|---|---|
| `workflow has no enter setting — it can never start` | A campaign that can never enrol anyone |
| `duplicate enter setting — a workflow has one trigger` | Two beginnings |
| `duplicate exit name "x" (first declared at …)` | Two goals reporting into one conversion number |
| `duplicate send window setting` | Ambiguous send hours |
| `duplicate reentry setting` | |
| `duplicate frequency cap setting` | |
| `duplicate enroll setting` | |
| `workflow version must be at least v1` | `v0` |
| `"x" is a reserved word and cannot be used as an event name` | `enter on event count` |
| `frequency cap must be at least 1, got 0` | A cap that blocks everything |
| `hold out must be between 1% and 99%, got 100%` | A holdout that holds out everyone |
| `split weights must sum to 100%, got 60%` | Contacts silently falling out of a split |
| `split arm weight must be at least 1%` | A dead arm |
| `repeat up to 0 — the bound must be at least 1` | A loop that never runs |
| `duration must be at least 1d` | `wait 0d`, `every 0h` |
| `enroll existing on a date-relative trigger requires a since <duration> bound` | Enrolling every contact who has ever had an anchor date |

The split one is worth dwelling on, because it is the check that pays for itself:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  split {
    30%: { send "a" via topic "marketing" }
    30%: { send "b" via topic "marketing" }
  }
}
```

Forty percent of the contacts in that split go nowhere. In a canvas you would
find out from the send volumes a week later. Here it does not compile.

## Structural — warnings

| Message | Why |
|---|---|
| `unreachable: this statement follows an exit` | Dead code after a terminal `exit` |
| `empty block` | An `if` arm that does nothing |
| `empty split arm` | |
| `empty repeat body` | |
| `empty timeout block — omit it to continue on timeout` | The empty arm and the absent arm mean the same thing; say the shorter one |
| `split with a single arm — did you mean hold out?` | A one-armed split is a holdout spelled the long way |
| `send window opens and closes at the same time` | `9am-9am` — almost always a typo |
| `wait up to 400d is a very long wait window` | Anything over a year |
| `control flow nested more than 5 levels deep — consider flattening` | A workflow nobody can read |

## Registry checks

These need `-registry`. Without it they are silently skipped, which is why a
clean `sendflow-lint` with no registry means considerably less than it looks.

| Message | Trigger |
|---|---|
| `unknown event "x"` | An `enter on event` that is not one of the eight |
| `unknown attribute "x"` | Any `attr.x` that does not exist |
| `unknown template "x"` | A `send` of a template you do not have |
| `unknown topic "x"` | A `via topic` that does not exist |
| `unknown list "x"` | An `add to list` that does not exist |
| `a date-relative trigger requires a datetime attribute; attr.plan is string` | `enter on 3d before attr.plan` |
| `wait until requires a datetime attribute; attr.plan is string` | `wait until attr.plan` |
| `cannot set number attribute attr.score to a string value` | `set attr.score = "high"` |
| `cannot set attr.subscription to "gold"; allowed values are …` | An enum attribute set outside its set |

Plus **every diagnostic SendQL itself produces**, for every embedded predicate —
unknown attributes, type mismatches, reserved words, and the absence lint. They
report at their real line and column in the `.flow` file. See
[SendQL's type rules](/docs/sendql/types).

## The absence warning, in a workflow

The one you will see most often, and the one most worth not ignoring:

**Syntax form, not a complete program:**

```sendflow
if attr.plan != "trial" { ... }
```

> `warning: predicate on 'attr.plan' excludes contacts where it is unknown — add 'or attr.plan is unknown' to include them, or 'has attr.plan' to silence this`

In a branch, an excluded contact does not vanish — they take the `else` arm. That
is how the wrong cohort ends up receiving the wrong email, and it is why this is
worth a warning even though it is frequently harmless. See
[the absence contract](/docs/sendql/absence).

## Reading a diagnostic

```
flows/winback.flow:8:3: warning: unreachable: this statement follows an exit
                  │ │  │
                  │ │  └─ severity: error or warning
                  │ └──── column
                  └────── line
```

Errors and warnings both carry a precise position, because parsing and analysis
both track one. The CLI prints a caret under the offending token.

Exit codes: `0` clean, `1` a parse failure or any error, `2` a usage or I/O
problem. Warnings alone do not fail.
