Linting Spec
Defines the complete set of Norq lint rules, their severities, how the linter
executes, and the structure of diagnostic output. The normative source of truth
for rule behaviour is the linter implementation under
crates/core/src/linter/; this document describes that behaviour in prose.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals, as shown here.
Overview
norq lint analyses notification templates without sending them. It parses
each channel template, resolves partials, compiles the result where necessary,
and emits a list of diagnostics. Diagnostics MUST be grouped by file and
channel in the JSON output.
The linter MUST run in three phases per channel template:
-
Parse phase — The template text is parsed into an AST. Parse failures MUST produce a
parse/error(Error) diagnostic and MUST abort further checks for that channel. -
AST phase — Rules that examine the parsed document structure: template rules, accessibility rules, channel-specific length and formatting rules, partial validation, and (for email) contrast checks.
-
Payload phase — The AST is compiled with the first available sample (or an empty data object if no samples exist). Rules that can only be evaluated against the compiled payload MUST run here: email HTML size, SMS segment count, Slack block count, push payload size, and Teams card size.
Structure rules MUST run once per notification bundle, before any template is
parsed. Theme-level contrast checks MUST run once per project, against
norq.config.yaml.
Execution
norq lint # lint all notifications in the project
norq lint transactional/order-shipped # lint one notification by ID
norq lint --json # emit JSON output (suitable for CI)
norq lint --config /path/to/norq.config.yaml # target a specific projectThe linter resolves the project config using the same two-phase discovery as
all other commands (walk up, then scan down). --config bypasses discovery and
targets the specified file or its parent directory.
Linting runs in parallel across notifications using Rayon. Order of results is non-deterministic across notification boundaries, but diagnostics within a single channel result are in parse order.
Exit Codes
| Exit code | Meaning |
|---|---|
0 |
No errors. Warnings and info may be present. |
1 |
At least one Error-severity diagnostic was emitted. |
Warnings and info diagnostics do not affect the exit code.
Severity Levels
Every diagnostic has exactly one severity.
| Severity | Symbol | Meaning |
|---|---|---|
error |
✗ |
Compilation will fail or produce incorrect output. The notification cannot be delivered as authored. Must be fixed before deploying. |
warning |
⚠ |
Output compiles but will likely cause problems in production: deliverability failures, accessibility violations, platform rejections, or silent drops. Should be fixed. |
info |
ℹ |
Optimization opportunity, style concern, or advisory notice. Does not block delivery but is worth addressing. |
Rule Categories
Rules are identified by a namespaced ID of the form category/rule-name. The
categories are:
| Category | Description |
|---|---|
structure/ |
Notification directory layout and file conventions |
template/ |
Template syntax, attributes, variable references, and partial usage |
partial/ |
Partial resolution and parameter passing |
a11y/ |
Accessibility (WCAG 2.1/2.2) |
email/ |
Email-specific deliverability, formatting, and spam |
sms/ |
SMS encoding and segment limits |
push/ |
Push notification title, body, and payload constraints |
whatsapp/ |
WhatsApp Business API template limits |
slack/ |
Slack Block Kit API limits |
msteams/ |
Microsoft Teams Adaptive Card limits |
parse/ |
Template parse failures (internal, not user-authored rules) |
Structure Rules
Structure rules run before any template is parsed. They validate the
notification directory layout against the conventions described in
docs/spec/structure.md. Violations at this layer often prevent the resolver
from finding the notification at all.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
structure/missing-type-folder |
Warning | A required type folder (system/, transactional/, promotional/) is absent |
structure/invalid-root-entry |
Error | An entry at the notifications/ root is not a type folder or _-prefixed directory |
structure/invalid-name |
Error | A directory name violates [a-z0-9]+(-[a-z0-9]+)* |
structure/too-deep |
Warning | A notification is nested beyond 4 levels from notifications/ |
structure/duplicate-channel-format |
Error | Both .md and .json exist for the same channel in one notification |
structure/no-channels |
Error | A notification directory contains no channel files |
structure/no-subdirectories |
Error | A notification directory contains subdirectories |
structure/all-channels-disabled |
Warning | All channel files in a notification have enabled: false |
structure/unrecognized-file |
Warning | A file in a notification directory is not a recognised channel file or data file |
structure/missing-type-folder
Severity: Warning
Norq requires exactly three top-level type folders under notifications/:
system/, transactional/, and promotional/. This diagnostic fires once per
missing folder. The resolver silently skips notification types whose folders do
not exist; this warning makes that omission explicit.
Fix: create the missing folder(s), or run norq init on the project to
scaffold missing structure without overwriting existing files.
structure/invalid-root-entry
Severity: Error
Only system/, transactional/, promotional/, and directories whose names
begin with _ (e.g., _shared/) are permitted at the notifications/ root.
Any other directory name, or any file at that level, is an invalid root entry.
The resolver returns a hard error when it encounters one during traversal.
Fix: move the entry into a type folder, rename it to start with _ if it is a
shared-partials directory, or delete it.
structure/invalid-name
Severity: Error
Notification, category, and subcategory directory names must match the pattern
[a-z0-9]+(-[a-z0-9]+)*: lowercase letters and digits only, with hyphens as
separators. No uppercase letters, underscores, spaces, or leading/trailing
hyphens. Invalid names break ID derivation and routing.
Valid: welcome, order-shipped, password-reset, q4-2024.
Invalid: Order_Shipped, welcome!, my notification.
Fix: rename the directory using only lowercase letters, digits, and hyphens.
structure/too-deep
Severity: Warning
The resolver supports at most 4 levels from notifications/: type / category /
subcategory / notification. Notifications nested deeper are silently ignored by
the resolver and will never be discovered or compiled.
Fix: flatten the directory structure. Maximum valid path:
notifications/transactional/account/security/password-reset/.
structure/duplicate-channel-format
Severity: Error
Both email.md and email.json (or the equivalent for another channel) exist
in the same notification directory. The resolver cannot determine which format
to use and returns a hard error. The same constraint applies to all channel
pairs: sms.md/sms.json, slack.md/slack.json, etc.
Fix: delete one of the two files. Use .md for Markdown templates and .json
only for channels that require native format (Slack Block Kit, Teams Adaptive
Card).
structure/no-channels
Severity: Error
A directory that contains no channel files (email.md, sms.md, slack.md,
push.md, whatsapp.md, msteams.md, or their .json variants) is not a
valid notification. The resolver will not recognise it; it cannot be compiled
or sent.
Fix: add at least one channel file, or delete the directory.
structure/no-subdirectories
Severity: Error
Notification directories must be leaf directories. Subdirectories inside a notification indicate a structural mistake — for example, accidentally nesting one notification inside another.
Fix: move any subdirectory out of the notification directory. Notification
directories may contain only channel files, data.schema.yaml,
data.samples.yaml, and tests.yaml.
structure/all-channels-disabled
Severity: Warning
When every channel file in a notification has enabled: false in its
frontmatter (Markdown mode) or "$norq": { "enabled": false } in its JSON
root (native mode), the notification cannot be delivered through any channel.
This is almost always a mistake — leftover from disabling channels during
development, or a misconfiguration.
Fix: set enabled: true on at least one channel, or remove the notification
if it is no longer needed.
structure/unrecognized-file
Severity: Warning
A file inside a notification directory that Norq does not recognise is ignored
during compilation. Recognised files are: the six channel files (.md or
.json), data.schema.yaml, data.samples.yaml, and tests.yaml.
An unrecognised filename often indicates a typo in a required filename (e.g.,
data-schema.yaml instead of data.schema.yaml).
Fix: check for typos and rename to a recognised filename, or move the file elsewhere.
Template Rules
Template rules apply to all channels. They catch problems in the template text itself: directive misuse, bad attributes, undefined variables, nullable-field access, and unsafe expressions. These rules run on the parsed AST and the raw source text.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
template/unknown-directive |
Error | A :::name directive is not in the recognised set |
template/undefined-variable |
Error | A {{path}} expression references a variable not declared in data.schema.yaml |
template/unknown-pipe |
Error | A | name pipe is not in the recognised set (with Levenshtein suggestion if close match) |
template/invalid-pipe-arg |
Error | A pipe argument is the wrong type (e.g. string where number expected) or the pipe arity is wrong |
template/unknown-attr |
Error | An unrecognised key appears in a {key="value"} attribute block |
template/unsupported-attr |
Error | A recognised attribute is used on a directive or channel that does not support it |
template/invalid-attr-value |
Error | An attribute value is not in the allowed set for that key |
template/unknown-style |
Error | A {.name} references a style that isn’t defined in brand.yaml styles: (or in a frontmatter brand.styles: override) |
template/unknown-partial-param |
Error | A {{> partial k=v}} passes a key the partial doesn’t declare as {{@k}} |
template/nullable-access |
Warning | A nullable variable is used directly without an :::if guard |
template/raw-expression |
Warning | A triple-brace {{{expr}}} expression outputs unescaped content |
template/thematic-break |
Warning | A bare --- line in the body may be an unintended horizontal rule |
template/directive-in-table |
Warning | A directive appears inside a Markdown table cell |
template/inline-directive |
Warning | A ::: directive marker appears mid-line rather than at line start |
template/nested-columns-unsupported |
Warning | A :::columns directive appears inside a :::col ancestor (including via :::section, which is parser-shorthand for :::columns + :::col). The inner columns are silently dropped during compilation |
template/empty-directive-body |
Warning | A directive has no content between its opening and closing markers |
template/action-without-link |
Warning | An :::action block contains no link |
template/table-in-fields |
Warning | A Markdown table is nested inside a :::fields directive |
template/missing-partial-param |
Info | A partial declares {{@k}} but the invocation doesn’t pass k |
editor/metadata-wrong-context |
Error | A registered $-prefixed editor-metadata attribute is used on the wrong node kind (e.g., $editable-by on an inline link) |
Runtime-only rules (fired by strict-mode validator, not the linter):
| Rule ID | Severity | Trigger |
|---|---|---|
| Strict: undefined-runtime-path | Error (halt) | Expression path is absent from the runtime data object (distinct from “present and null”) |
| Strict: unknown-pipe | Error (halt) | Pipe name isn’t in KNOWN_PIPES |
| Strict: invalid-pipe-arg | Error (halt) | Pipe argument fails arity or type check against pipe_spec |
See Strict mode below for opt-out.
template/unknown-directive
Severity: Error
Recognised directive names are: header, footer, action, callout,
highlight, centered, hero, fields, col, columns, media, list,
raw, social, if, else, each, table. Any other :::name is
unrecognised and is silently dropped during compilation — the content it wraps
never appears in the output. This is guaranteed content loss.
Fix: correct the spelling. See the template directives reference for the full list.
template/undefined-variable
Severity: Error
A {{path}} expression references a variable path not declared in
data.schema.yaml. At runtime the expression renders as an empty string,
which produces blank output where user data (name, order ID, etc.) was
expected. This rule only fires when a data.schema.yaml is present; without a
schema, variable references cannot be validated.
Fix: add the variable to data.schema.yaml, or fix the typo in the expression.
template/unknown-pipe
Severity: Error
A {{ x | name }} expression uses a pipe name that isn’t in the recognised
list (see KNOWN_PIPES in crates/core/src/compiler/pipes.rs). The error
message includes a Levenshtein-based suggestion when a close match exists
(uppercse → uppercase).
Fix: correct the spelling or remove the pipe.
template/invalid-pipe-arg
Severity: Error
A pipe argument doesn’t match the pipe’s declared shape:
- Wrong type:
{{ x | truncate "abc" }}(string literal where number expected) - Unknown path:
{{ x | truncate kdsjds }}(bare unknown word that is neither a numeric literal nor a schema-known path) - Wrong arity:
{{ x | replace "one" }}(replace requires 2 args) - Too many args:
{{ x | uppercase "extra" }}(zero-arg pipe with arg)
Fix: consult the pipe’s expected arg types and arity in the expressions reference and correct the call.
Strict mode
Inspired by Handlebars’ strict mode. A pre-compile runtime validator in
crates/core/src/validator.rs refuses to send a notification when the template
references data missing from the runtime data object, uses unknown pipes, or
passes wrong-type pipe arguments.
On by default. Opt out with:
strict: falseinnorq.config.yaml(project-wide)--no-strictonnorq send/norq compile(per-invocation)- Per-SDK call:
client.send(id, { strict: false, ... })(Node),client.send(..., strict=False)(Python),SendOpts{Strict: &f}(Go),new SendOpts().strict(false)(Java),client.send(..., strict: false)(Ruby)
Legitimate runtime exceptions:
:::if path— paths inside the consequent are implicitly guarded:::each items as item— loop binding is in scope inside the body{{ path | default "..." }}— thedefaultpipe makes missing paths OK- Explicit null values (
{ "field": null }) — present-but-null is distinct from missing-key and is allowed
template/nullable-access
Severity: Warning
A variable is nullable (not in the required array of its parent schema object)
and is referenced directly outside a guard. The :::if guard is the canonical
way to test for existence before use. Without it, the variable may be absent in
some payloads, rendering as an empty string or producing unexpected layout.
Fix: wrap the usage in an :::if guard:
:::if user.middleName
{{user.middleName}}
:::template/raw-expression
Severity: Warning
A triple-brace expression ({{{value}}}) outputs its value without HTML
escaping. If the value contains user-supplied content this is an XSS
vulnerability. Even for non-user content, unescaped output can break the
compiled email’s HTML structure.
Fix: use double-brace {{value}} unless you are deliberately injecting trusted
HTML. If you do need raw HTML output, confirm the value is safe and controlled.
template/thematic-break
Severity: Warning
A bare --- on its own line is ambiguous: it is both valid YAML frontmatter
syntax and valid Markdown horizontal-rule syntax. In the template body it
compiles to an <hr> element, which may not be the author’s intent. It also
triggers false positives in frontmatter parsers when placed near the top of the
file.
Fix: use *** for intentional horizontal rules in the template body. Reserve
--- for frontmatter delimiters only.
template/directive-in-table
Severity: Warning
Markdown table syntax treats | as a column separator. A :::directive inside
a table cell breaks the table’s parse structure, preventing the directive from
rendering correctly.
Fix: move the directive outside the table. If you need structured data alongside a directive, place the directive before or after the table.
template/inline-directive
Severity: Warning
The ::: syntax is a block-level construct and must appear at the start of a
line. A :::directive that appears mid-sentence is not recognised as a
directive and will be emitted as literal text in the output.
Fix: move the directive to its own line with no preceding characters.
template/nested-columns-unsupported
Severity: Warning
The :::columns directive is only rendered as a multi-column layout when it
appears at the top level (or inside containers that pass through to the
section emit path). When :::columns is nested inside a :::col ancestor,
the email compiler silently drops the inner directive and emits its
children as flat content — the layout the author wrote does not appear in
the output.
Because :::section is parser-shorthand for :::columns + :::col (a
single-column section), any :::columns written inside :::section is de
facto inside a :::col after parse and triggers the same silent fail. The
lint covers both surface syntaxes.
Fix: place column blocks as siblings to the section, sharing a bg
token so they read as one visual section:
::: section {bg="#f8f8fa" padding="72 24 24 24"}
## Feature heading
Body copy + CTA + image.
:::
::: columns {bg="#f8f8fa" padding="0 24 48 24"}
::: col
Left sub-feature.
:::
::: col
Right sub-feature.
:::
:::The bg continuity across the two MJML sections lets them render as a
single visual block. Complementary top/bottom padding (72 ... 24 on the
section, 0 ... 48 on the columns) removes the seam.
template/unknown-attr
Severity: Warning
The recognised block attribute keys are: align, bg, bg-image,
bg-size, bg-repeat, bg-position, color, padding, size, weight,
spacing, url, width, valign, variant, primary, secondary,
danger, success, warning, button, button.secondary,
button.success, button.danger, button.warning, full, thickness,
border-radius. Any other key in a {key="value"} attribute block is
silently ignored during compilation.
Fix: check the attribute name against the directive documentation. The rule message identifies the unrecognised key.
template/unsupported-attr
Severity: Warning
Some attributes are valid in general but not supported by a specific directive
or channel. For example, layout attributes (size, weight, spacing, bg,
color, align, padding) are email-only; using them in an SMS or push
template has no effect. The attribute is accepted but silently ignored during
compilation.
Fix: verify which attributes a directive supports on the target channel. Check the relevant channel documentation.
template/invalid-attr-value
Severity: Warning
Attributes that accept enumerated values (such as size and align) emit this
diagnostic when an invalid value is provided. The invalid value is silently
ignored during compilation, reverting to the default.
Valid size values: xs, sm, md, lg, xl, 2xl, 3xl, 4xl.
Valid align values: left, center, right.
Fix: use a value from the allowed set. The diagnostic message identifies the attribute and the rejected value.
editor/metadata-wrong-context
Severity: Error
Attribute keys starting with $ are reserved for editor and tooling metadata
(see Editor Metadata Attributes
in the syntax spec). The linter exempts all $-keys from
template/unknown-attr and template/unsupported-attr. Registered metadata
attributes (currently only $editable-by) can declare additional constraints;
this rule fires when a block_only-constrained attribute appears on an inline
element (e.g., $editable-by on an inline link or image).
Fix: move the $-attribute from the inline element to the enclosing block.
<!-- Wrong: $editable-by on inline link -->
See [our docs](https://example.com){$editable-by="admin"}.
<!-- Correct: lock the surrounding block -->
::: section {$editable-by="admin"}
See [our docs](https://example.com).
:::Unregistered $-prefixed attributes (e.g., $customTag="v1") are not
context-checked — they can appear on any node without warning, so editors and
vendor tooling can store arbitrary metadata without a spec PR per key.
template/empty-directive-body
Severity: Warning
A directive with no content between its opening :::name and closing :::
markers compiles to an empty block. This is almost always a mistake: content
was accidentally deleted or the directive was left as a placeholder.
Fix: add content to the directive body, or remove the directive entirely.
template/action-without-link
Severity: Warning
An :::action block that contains no link produces a button with no
destination URL. On email this is an unclickable button; on other channels it
may be dropped entirely from the compiled output.
Fix: add a link inside the action block:
:::action
[Track Shipment]({{order.trackingUrl}})
:::template/table-in-fields
Severity: Warning
The :::fields directive compiles to a key-value table layout. A Markdown
table nested inside :::fields conflicts with that layout and is treated as
raw text rather than a nested table.
Fix: move the table outside the :::fields block. Use :::fields for simple
key-value pairs only.
Partial Rules
Partial rules validate {{> name}} include expressions and their parameter
passing. They run against the raw source text, before expansion.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
partial/not-found |
Error | A {{> name}} reference cannot be resolved for the current channel |
template/unknown-partial-param |
Warning | A parameter passed to a partial is not defined in that partial |
template/missing-partial-param |
Info | A partial defines a parameter that was not passed in the invocation |
partial/not-found
Severity: Error
A {{> name}} include that cannot be resolved for the current channel means
the partial content is absent from the compiled output. The tag is left as-is
in the output, producing broken or incomplete notifications. Resolution walks
up the directory tree from the notification’s location, checking each
_shared/ directory at each scope level.
A partial must exist in a format compatible with the channel: .md works for
all channels, .mjml for email only, .blocks.json for Slack only,
.card.json for Teams only.
Fix: add the partial to a _shared/ directory in an appropriate scope. If the
partial exists for a different channel (e.g., .mjml for email), add a .md
fallback for other channels.
template/unknown-partial-param
Severity: Warning
A partial invocation passes a parameter name that is not referenced as
{{@name}} inside the partial’s content. This is almost always a typo in the
parameter name or a stale parameter left over after refactoring the partial.
The diagnostic includes a suggestion when a similar parameter name exists.
Fix: correct the parameter name, or remove the parameter if the partial no longer uses it.
template/missing-partial-param
Severity: Info
A partial uses {{@name}} internally but the invocation site did not pass a
value for that parameter. The {{@name}} reference is left unexpanded in the
output.
Fix: pass the required parameter at the invocation site:
{{> partial-name param=some.value}}.
Accessibility Rules
Accessibility rules enforce WCAG 2.1/2.2 compliance and screen-reader best practices. They apply to all channels and run on the parsed AST.
Rule Reference
| Rule ID | Severity | WCAG | Trigger |
|---|---|---|---|
a11y/alt-text-quality |
Warning | 1.1.1 | Image alt text is generic or filename-based |
a11y/alt-text-length |
Info | 1.1.1 | Alt text exceeds 125 characters |
a11y/image-only-link |
Warning | 1.1.1 / 2.4.4 | A link contains only an image with no text fallback |
a11y/link-text-is-url |
Warning | 2.4.4 | Link text is a raw URL |
a11y/table-missing-headers |
Warning | 1.3.1 | A data table has no header row |
a11y/empty-heading |
Warning | 1.3.1 | A heading element has no text content |
a11y/excessive-emoji |
Info | — | More than 2 emoji in subject/preheader, or more than 3 in a heading or body paragraph |
a11y/duplicate-heading-text |
Info | 2.4.6 | Same-level headings with identical text |
a11y/adjacent-duplicate-links |
Info | 2.4.4 | Consecutive links pointing to the same URL |
a11y/alt-text-quality
Severity: Warning
WCAG: 1.1.1 Non-text Content
Screen readers announce alt text to describe images. Generic values — the
image’s filename, image, logo, banner, photo — provide no useful
information to visually impaired users. Spam filters also penalise missing or
generic alt text.
Fix: replace generic alt text with a description that conveys the image’s purpose:
 <!-- bad: filename -->
 <!-- good: describes content -->a11y/alt-text-length
Severity: Info
WCAG: 1.1.1 Non-text Content
Alt text over 125 characters becomes tedious for screen-reader users. Long descriptions are better placed in surrounding body text, where they are available to all readers rather than only those using assistive technology.
Fix: shorten alt text to under 125 characters. Move detailed descriptions into the body copy.
a11y/image-only-link
Severity: Warning
WCAG: 1.1.1 / 2.4.4
A link that contains only an image and no text fallback becomes invisible when the image fails to load — a common situation in email clients that block images by default. There is also no text for screen readers to announce as the link purpose.
Fix: add text alongside the image inside the link, or use a text link with the image as decoration. Alternatively, replace the image link with a button link:
[Shop Now](https://shop.example.com){button}a11y/link-text-is-url
Severity: Warning
WCAG: 2.4.4 Link Purpose
Screen readers enumerate all links on a page. Raw URLs like
https://example.com/orders/12345/confirmation are meaningless out of context
and force the user to listen to the full URL being read aloud.
Fix: use descriptive link text:
[https://example.com/track](https://example.com/track) <!-- bad -->
[Track your order](https://example.com/track) <!-- good -->a11y/table-missing-headers
Severity: Warning
WCAG: 1.3.1 Info and Relationships
Screen readers use table headers to announce column context as users navigate cells. Without a header row, data tables become a grid of unlabelled values with no way to understand what each column represents.
Fix: add a header row (a row using | separators followed by a separator row
using ---):
| Product | Quantity | Price |
|---------|----------|-------|
| Widget | 2 | $10 |a11y/empty-heading
Severity: Warning
WCAG: 1.3.1 Info and Relationships
Screen-reader users navigate by headings to scan document structure. An empty heading creates a dead entry in the heading list and produces a gap in the document outline.
Fix: add text content to the heading, or remove it if it was added by mistake.
a11y/excessive-emoji
Severity: Info
Screen readers announce each emoji by its full Unicode name (e.g., “face with tears of joy”, “rocket”, “fire”). A subject line with five emoji forces the listener through five verbose announcements before reaching the message content.
This rule fires when:
- A subject or preheader contains more than 2 emoji.
- A single body paragraph or heading contains more than 3 emoji.
Fix: reduce emoji count. Use one emoji for emphasis rather than several for decoration.
a11y/duplicate-heading-text
Severity: Info
WCAG: 2.4.6 Headings and Labels
Screen-reader users who navigate by heading list see the same text appear twice with no way to distinguish which section is which. Unique headings help users jump directly to the section they want.
Fix: differentiate heading text — for example, “Shipping Details” and “Billing Details” rather than two headings both reading “Details”.
a11y/adjacent-duplicate-links
Severity: Info
WCAG: 2.4.4 Link Purpose
Two consecutive links pointing to the same URL appear as separate entries in screen-reader link lists, creating confusion and requiring extra navigation steps to reach the same destination.
Fix: combine into a single link:
[View order](https://example.com/order) [details](https://example.com/order) <!-- bad -->
[View order details](https://example.com/order) <!-- good -->Email Rules
Email rules check deliverability, formatting, and spam-filter characteristics.
They run only on email.md files (or email.json in native format). Rules
that depend on the compiled HTML size run in the payload phase; all others run
in the AST phase.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
email/missing-subject |
Warning | No subject key in frontmatter, or the value is blank |
email/missing-preheader |
Warning | No preheader key in frontmatter |
email/missing-alt |
Warning | An image has no alt text attribute |
email/heading-hierarchy |
Warning | Heading levels skip (e.g., h1 → h3) |
email/link-text-quality |
Warning | Link text is non-descriptive |
email/low-contrast |
Warning | A text/background colour pair fails WCAG 2.1 AA contrast |
email/url-shortener |
Warning | A link uses a known URL-shortening service |
email/text-to-image-ratio |
Warning | The compiled email is image-heavy with little text |
email/size-limit |
Warning | Compiled HTML exceeds 102 KB |
email/subject-length |
Info | Subject line exceeds 60 characters |
email/preheader-length |
Info | Preheader is outside the 40–130 character range |
email/spam-words |
Info | Subject or preheader contains known spam trigger phrases |
email/subject-all-caps |
Info | Subject line is entirely uppercase |
email/subject-excessive-punctuation |
Info | Subject contains two or more consecutive ! or ? |
email/all-caps-body |
Info | A body text block is entirely uppercase |
email/missing-unsubscribe |
Info | No unsubscribe link found (promotional notifications only) |
email/font-size-minimum |
Info | A size="xs" attribute produces 10 px text, below the accessibility minimum |
email/missing-subject
Severity: Warning
Most email providers require a subject line. Without one, sends may be rejected outright or delivered with a blank subject, which severely reduces open rates.
Fix: add subject: to the frontmatter at the top of email.md:
---
subject: "Your order has shipped"
---email/missing-preheader
Severity: Warning
When preheader is absent, email clients fill the inbox preview line with the
first text from the email body — often a navigation link, disclaimer, or
“View in browser” prompt. This wastes the premium preview space that could be
used to drive opens.
Fix: add preheader: to the frontmatter:
---
subject: "Your order has shipped"
preheader: "Track your package and see delivery details"
---email/missing-alt
Severity: Warning
Images without alt text fail accessibility standards and increase spam score.
Screen readers skip the image entirely, and spam filters penalise image-only
content. Note that this rule checks for the presence of alt text, not its
quality; quality is checked by a11y/alt-text-quality.
Fix: add a descriptive alt attribute to every image:
email/heading-hierarchy
Severity: Warning
Screen readers use heading levels to navigate documents. Skipping a level (e.g.,
# h1 directly to ### h3 with no ## h2) breaks this structure, causing
confusion for users with assistive technology.
Fix: use headings in sequential order. Start with # (h1), then ## (h2),
then ### (h3). Do not skip levels.
email/link-text-quality
Severity: Warning
Non-descriptive link text — “click here”, “here”, “this link”, “read more”, “learn more”, “link” — is meaningless out of context. Screen readers enumerate links by their text; generic text makes navigation impossible. Spam filters also score these phrases negatively.
Fix: use text that explains the link’s destination:
[Track your shipment](https://example.com/track/12345) <!-- good -->
[Click here](https://example.com/track/12345) <!-- bad -->email/low-contrast
Severity: Warning
WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal-size text and 3:1 for large text (18 px+ or 14 px+ bold). Text/background colour pairs that fall below these thresholds are unreadable for users with low vision.
This rule checks colour pairs produced by the brand — brand.tokens.colors.brand,
button, body, heading, and related entries in brand.yaml.
Per-directive colour overrides are checked where they can be statically
resolved.
Fix: adjust the offending colour tokens in brand.yaml (or a frontmatter
brand: override) until all pairs pass. Use a contrast checker (e.g., the
WebAIM Contrast Checker) to verify ratios.
email/url-shortener
Severity: Warning
URL-shortening services (bit.ly, tinyurl.com, t.co, goo.gl, ow.ly, is.gd, buff.ly, adf.ly, bl.ink, short.io) hide the destination domain from spam filters and recipients alike. Many corporate email gateways block messages containing shortened URLs outright.
Fix: use full, direct URLs. If link length is a concern in plain-text contexts, use descriptive link text to present a long URL rather than shortening it.
email/text-to-image-ratio
Severity: Warning
Emails composed almost entirely of images trigger spam filters because spammers use image-only emails to conceal content from text scanners. A low text-to-image ratio correlates strongly with promotion and spam classification.
Fix: add more text content alongside images. Describe what the image shows, add a caption, or include body copy.
email/size-limit
Severity: Warning
Gmail clips emails larger than 102 KB, displaying a “Message clipped” link in place of the remaining content. Hidden content often includes CTAs and unsubscribe links — both critical for the message to function.
This rule runs in the payload phase: the template is compiled with the first available sample and the resulting HTML is measured.
Fix: reduce inline content. Break long emails into shorter ones, move verbose copy to landing pages, and avoid inlining large base64-encoded images.
email/subject-length
Severity: Info
Mobile clients (Gmail on Android, Apple Mail on iPhone) typically truncate subjects around 40–60 characters. Recipients see a partial subject and may not open.
Fix: keep subjects under 60 characters. Front-load the most important words.
email/preheader-length
Severity: Info
A preheader under 40 characters leaves inbox preview space blank. One over 130 characters is truncated mid-sentence in most clients.
Fix: aim for 40–130 characters. Test across Gmail, Apple Mail, and Outlook.
email/spam-words
Severity: Info
Spam filters score messages based on known trigger phrases in the subject and
preheader fields. The detected phrases are: free, winner,
congratulations, act now, limited time, click here, buy now,
order now, risk free, no obligation, 100% free, double your,
earn money, cash bonus.
Fix: rephrase to remove trigger words. Focus on specific value rather than generic urgency.
email/subject-all-caps
Severity: Info
ALL-CAPS subjects are a classic spam signal and read as aggressive to recipients. Many filters penalise them.
Fix: use standard title case or sentence case.
email/subject-excessive-punctuation
Severity: Info
Subjects containing !! or ?? (two or more consecutive identical marks) are
flagged by spam filters and read as shouting.
Fix: use a single punctuation mark, or none at all.
email/all-caps-body
Severity: Info
A body text block written entirely in uppercase reduces readability and is a secondary spam signal when combined with other indicators.
Fix: use normal casing. Use **bold** for emphasis rather than uppercase.
email/missing-unsubscribe
Severity: Info
This rule fires only on promotional notification type templates. CAN-SPAM
(US) requires an unsubscribe mechanism in all commercial email. Gmail and Yahoo
both enforce one-click unsubscribe for bulk senders as a deliverability
requirement. A missing unsubscribe link risks regulatory exposure and inbox
placement.
Fix: add an unsubscribe link, typically in a :::footer block:
:::footer
[Unsubscribe]({{unsubscribeUrl}}) · [Preferences]({{preferencesUrl}})
:::email/font-size-minimum
Severity: Info
The xs size token maps to 10 px. The WCAG 1.4.4 accessible minimum and the
broadly-accepted practical minimum for email body text is 14 px. Text at 10 px
is unreadable for many users, particularly on mobile.
Fix: use sm (12 px) at minimum, or md (16 px) for body copy. Reserve xs
only for legal-text contexts where you explicitly accept the tradeoff.
SMS Rules
SMS rules focus on encoding and cost. Character encoding and segment count have a direct impact on billing and delivery reliability. Rules run on the raw template source (AST phase) and on the compiled SMS payload (payload phase).
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
sms/no-images |
Warning | Image syntax appears in an SMS template |
sms/no-tables |
Warning | Markdown table syntax appears in an SMS template |
sms/encoding-warning |
Info | Non-GSM-7 characters force UCS-2 encoding |
sms/extended-gsm-chars |
Info | Extended GSM-7 characters that each consume two character slots |
sms/message-length |
Info | The template text will likely exceed one SMS segment |
sms/segment-count |
Warning (3+ segments) / Info (2 segments) | The compiled message requires multiple segments |
sms/no-images
Severity: Warning
SMS is a plain-text protocol. Image syntax () in an SMS template
will not render — recipients will see raw Markdown, a broken link, or nothing
at all, depending on the provider.
Fix: remove image syntax from sms.md. If you need to share a visual, include
a plain URL in the message text that links to the image.
sms/no-tables
Severity: Warning
Markdown table syntax (pipe-separated columns) does not translate to SMS. The pipe characters and separator dashes will appear as raw text, making the message unreadable.
Fix: remove tables. Present the same information as plain prose or a simple list.
sms/encoding-warning
Severity: Info
GSM-7 encoding allows 160 characters per segment. Characters outside the GSM-7 basic set — emoji, accented letters, smart quotes, curly apostrophes, or any other Unicode character not in the 128-character GSM-7 repertoire — force UCS-2 encoding. UCS-2 drops segment capacity to 70 characters. A message that fits in one GSM-7 segment may require three UCS-2 segments, tripling delivery cost.
Fix: avoid emoji and non-ASCII characters in SMS templates. Use straight
quotes (', ") instead of typographic quotes. If non-ASCII characters are
required, consciously accept the encoding cost and check segment count.
sms/extended-gsm-chars
Severity: Info
The following characters are technically in the GSM-7 character set but occupy
two character slots in GSM-7 encoding: {, }, [, ], \, ~, |,
^, and €. A message containing 10 of these characters consumes 10 extra
character slots beyond what the visible count suggests.
Fix: be aware of double-counting when estimating message length. If you are near a segment boundary, replacing extended characters with alternatives can keep you within the previous segment.
sms/message-length
Severity: Info
A single SMS segment holds 160 GSM-7 characters or 70 UCS-2 characters. Multi-segment messages cost more and may arrive out of order on some carriers. This rule fires when a static analysis of the template text suggests the compiled message will exceed one segment.
Fix: shorten the message. Remove filler words and consider what information is truly essential in an SMS context.
sms/segment-count
Severity: Warning (3+ segments) / Info (2 segments)
This rule runs in the payload phase against the compiled SMS message. Each segment is billed separately. A 3-segment message costs three times a single-segment message. Carriers may also apply concatenation fees. Long messages also have higher delivery failure rates on some networks.
The severity is graduated: 2 segments triggers Info (acceptable for important messages), 3 or more segments triggers Warning (worth revisiting the content strategy).
Fix: shorten the message to fit within fewer segments.
Push Rules
Push notification rules exist because mobile platforms enforce hard payload and display limits. Violations result in silent delivery failures or truncated content with no error in application logs (unless the provider response is explicitly checked).
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
push/missing-title |
Warning | No title source found in the template |
push/title-length |
Warning | The resolved title exceeds 50 characters |
push/body-length |
Info | The body text exceeds 120 characters |
push/payload-size |
Warning | The compiled payload exceeds 4 KB |
push/too-many-actions |
Warning | More than 2 action buttons |
push/unknown-ios-key |
Warning | ios: frontmatter block contains an unrecognised key |
push/unknown-android-key |
Warning | android: frontmatter block contains an unrecognised key |
push/unknown-web-key |
Warning | web: frontmatter block contains an unrecognised key |
push/invalid-priority |
Error | android.priority is not normal or high |
push/invalid-interruption-level |
Error | ios.interruption-level is not one of the four allowed values |
push/missing-title
Severity: Warning
Push notifications without a title display the app name (iOS) or no title at
all (some Android configurations). A missing title reduces click-through rate
and looks unprofessional. The linter considers any of these as a valid title
source: a title: key in frontmatter, an :::header directive, or a #
heading at the top of the template.
Fix: provide a title using any of these forms:
---
title: "Your order has shipped"
---or an :::header directive, or a # Heading at the top of the body.
push/title-length
Severity: Warning
iOS truncates push notification titles at approximately 50 characters on lock screen and notification centre. Content beyond that is invisible to the recipient.
Fix: keep titles under 50 characters. Put the key information first.
push/body-length
Severity: Info
In collapsed notification view (the default), body text is typically truncated around 120 characters. Recipients who do not expand the notification miss everything after the cutoff.
Fix: front-load the most important information. Keep the first 120 characters self-contained so the message is useful even if not expanded.
push/payload-size
Severity: Warning
APNs (iOS) and FCM (Android) both enforce a 4 KB payload limit. Payloads over this limit are silently rejected — the notification never delivers, and no error appears in application logs unless the provider response is explicitly inspected.
This rule runs in the payload phase after compilation.
Fix: reduce content size. Store large data server-side and fetch it when the notification is tapped rather than embedding it in the payload.
push/too-many-actions
Severity: Warning
Cross-platform support for push action buttons is inconsistent. iOS supports up to 4 notification categories, but most practical UIs show 2. Android notification channels cap at 3. Beyond 2 actions, behaviour varies widely across devices and OS versions.
Fix: limit to 2 action buttons for reliable cross-platform behaviour.
push/unknown-ios-key
Severity: Warning
The ios: frontmatter block accepts a fixed set of platform-specific override
keys. Any key outside the allowed set is silently ignored at compile time,
which means the intended override has no effect.
Allowed keys: sound, badge, category, thread-id, mutable-content,
content-available, interruption-level.
Fix: correct the key spelling. Consult the APNs payload reference for the exact
field names; keys used here map directly to the APNs aps dictionary.
push/unknown-android-key
Severity: Warning
The android: frontmatter block accepts a fixed set of platform-specific
override keys. Any key outside the allowed set is silently ignored at compile
time, which means the intended override has no effect.
Allowed keys: channel, channel_id, priority, color, icon, sound,
tag, ttl.
Fix: correct the key spelling. Consult the FCM Android notification object reference for the exact field names.
push/unknown-web-key
Severity: Warning
The web: frontmatter block accepts a fixed set of Web Push notification
override keys. Any key outside the allowed set is silently ignored at compile
time, which means the intended override has no effect.
Allowed keys: icon, badge, image, requireInteraction, actions, tag,
renotify.
Fix: correct the key spelling. Consult the Web Notification API specification for the exact field names.
push/invalid-priority
Severity: Error
android.priority controls FCM delivery urgency. The only accepted values are
normal (battery-friendly, may be delayed) and high (wakes the device
immediately, required for time-sensitive alerts). Any other value is rejected
by the FCM API and results in a delivery failure.
Fix: set android.priority to normal or high:
---
android:
priority: high
---push/invalid-interruption-level
Severity: Error
ios.interruption-level maps directly to the APNs interruption-level field,
which governs whether iOS suppresses the notification during Focus modes and
whether it can override Do Not Disturb. Any value not in the allowed set is
rejected by the APNs gateway.
Allowed values:
| Value | Behaviour |
|---|---|
passive |
Delivered silently; no screen wake, no sound. |
active |
Standard delivery (default when field is absent). |
time-sensitive |
Breaks through Focus filters; requires the Time Sensitive Notifications entitlement. |
critical |
Overrides silent mode and Do Not Disturb; requires an Apple entitlement and user grant. |
Fix: set ios.interruption-level to one of the four values above.
WhatsApp Rules
WhatsApp Business API enforces strict limits on template structure during the Meta approval process. Violations result in template rejection before any message is sent, not at send time.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
whatsapp/too-many-buttons |
Warning | More than 3 buttons in an action block |
whatsapp/body-length |
Warning | Body text exceeds 1,024 characters |
whatsapp/header-length |
Warning | Header text exceeds 60 characters |
whatsapp/footer-length |
Warning | Footer text exceeds 60 characters |
whatsapp/button-label-length |
Warning | A button label exceeds 20 characters |
whatsapp/cta-button-limit |
Warning | More than 2 CTA (URL/phone) buttons |
whatsapp/too-many-buttons
Severity: Warning
WhatsApp allows a maximum of 3 quick-reply or action buttons per template. Templates with more than 3 are rejected by the Meta API during submission.
Fix: reduce to 3 or fewer buttons. Prioritise the most important actions.
whatsapp/body-length
Severity: Warning
WhatsApp template body text is capped at 1,024 characters by the API. Templates that exceed this are rejected during Meta approval and cannot be sent.
Fix: shorten the body. If your message requires more context, link to a webpage.
whatsapp/header-length
Severity: Warning
WhatsApp header text is limited to 60 characters. Longer headers are rejected by the API.
Fix: keep header text concise — treat it as a subject line, not a paragraph.
whatsapp/footer-length
Severity: Warning
WhatsApp footer text is limited to 60 characters. Longer footers are rejected by the API. Common uses for footer text: brand name, opt-out notice.
Fix: keep footer text short.
whatsapp/button-label-length
Severity: Warning
Button labels in WhatsApp templates are capped at 20 characters. Labels that exceed this cause the template to be rejected or truncated.
Fix: use short, action-oriented labels: “Track Order”, “View Details”, “Get Offer”.
whatsapp/cta-button-limit
Severity: Warning
WhatsApp allows a maximum of 2 call-to-action buttons (URL or phone type) per template. Exceeding this limit causes template rejection.
Fix: limit to 2 CTA buttons. Use quick-reply buttons for additional options if needed.
Slack Rules
Slack’s Block Kit API enforces limits on blocks and element counts. Payloads that exceed them receive API errors and fail to post, silently from the author’s perspective.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
slack/block-count |
Warning | More than 50 blocks in the payload |
slack/section-text-length |
Warning | A section block’s text exceeds 3,000 characters |
slack/actions-count |
Warning | An actions block contains more than 5 elements |
slack/block-count
Severity: Warning
The Slack API rejects messages with more than 50 blocks. If the block count exceeds this limit, the entire message fails to post — the API returns an error and the notification is silently lost.
This rule runs in the payload phase against the compiled Block Kit JSON.
Fix: reduce content. Consolidate related sections, use fewer blocks, or split the notification into multiple messages.
slack/section-text-length
Severity: Warning
Slack limits section block text to 3,000 characters. Sections that exceed this are rejected by the API.
Fix: split long sections into multiple section blocks, or move detailed content to a linked webpage.
slack/actions-count
Severity: Warning
An actions block in Slack supports a maximum of 5 interactive elements (buttons, selects, etc.). More than 5 causes the API to reject the entire message payload.
Fix: limit action blocks to 5 elements. Combine or remove less important actions.
MS Teams Rules
Teams Incoming Webhook cards have payload size and action limits that result in webhook rejection when exceeded.
Rule Reference
| Rule ID | Severity | Trigger |
|---|---|---|
msteams/card-payload-size |
Warning | Card payload JSON exceeds 28 KB |
msteams/action-count |
Warning | More than 6 top-level actions |
msteams/card-payload-size
Severity: Warning
Microsoft Teams Incoming Webhooks reject card payloads larger than 28 KB. The webhook returns an error and the notification does not appear in the channel.
This rule runs in the payload phase against the compiled Adaptive Card JSON.
Fix: reduce card content. Remove unnecessary images, shorten text, and avoid embedding large data in the card body. Link out to detailed content instead.
msteams/action-count
Severity: Warning
Teams Adaptive Cards support up to 6 top-level actions. Actions beyond the limit are pushed into an overflow menu that most users will not discover.
Fix: limit the card to 6 actions. Surface only the most important actions directly; remove or consolidate the rest.
Linter Configuration
The linter has no configuration today. All rules are always active; there is no
mechanism to suppress individual rules or override severities in
norq.config.yaml or in frontmatter.
Future versions will add per-project and per-template rule suppression.
JSON Output
norq lint --json emits a protocol envelope with a results key. The value is
an array of result objects, one per channel file with diagnostics.
Envelope
{
"ok": true,
"data": {
"results": [ ... ]
}
}ok is true whenever the command exits successfully (including when warnings
are present). It is false only when the command itself fails with an
unrecoverable error (e.g., config not found). Exit code 1 is used when at
least one Error-severity diagnostic is present — this is independent of the
ok field.
Result Object
Each element of results corresponds to a single channel file:
{
"file": "notifications/transactional/welcome/email.md",
"channel": "email",
"diagnostics": [ ... ]
}| Field | Type | Description |
|---|---|---|
file |
string | Relative path to the channel file that produced the diagnostics |
channel |
string | Channel name: email, sms, slack, push, whatsapp, msteams |
diagnostics |
array | Zero or more diagnostic objects |
Result objects with zero diagnostics are omitted from the output.
Diagnostic Object
{
"rule": "email/missing-subject",
"severity": "warning",
"message": "Email template has no `subject` in frontmatter.",
"position": {
"start_line": 1,
"start_col": 1,
"end_line": 1,
"end_col": 3
}
}| Field | Type | Description |
|---|---|---|
rule |
string | Rule identifier (e.g., email/missing-subject) |
severity |
string | "error", "warning", or "info" |
message |
string | Human-readable description of the issue |
position |
object or absent | Source location; omitted when the diagnostic is not tied to a specific line |
Position Object
{
"start_line": 4,
"start_col": 1,
"end_line": 4,
"end_col": 25
}Line and column numbers are 1-based. end_line equals start_line for
single-line diagnostics. Position is absent for diagnostics that apply to the
whole file (e.g., email/missing-subject when there is no frontmatter) and for
structure-level diagnostics that apply to a directory rather than a file.
When a diagnostic originates inside a partial that is expanded into the
template, the reported position is remapped to the {{> partial-name}}
invocation line in the parent template rather than the line inside the partial
file.
Runtime Errors (informational)
The linter only flags issues that can be detected statically. The following
codes can ONLY be raised at send time, by providers or by the strict-mode
validator, because they depend on runtime data, per-token metadata, or
network state. Authors writing CI pipelines that gate on norq lint MUST
NOT expect these to fire there — they require an actual norq send (or
client.send(...)) to surface.
| Code | Source | Notes |
|---|---|---|
push/badge-must-be-number |
FCM, APNs, Expo | Frontmatter ios.badge interpolated to a non-numeric string. |
push/invalid-environment |
APNs | Token environment value is not "sandbox" or "production". |
push/invalid-priority |
FCM | Frontmatter android.priority interpolated to a non-{normal|high} value. |
push/missing-platform |
Push routing | Token has no platform field. |
push/invalid-platform |
Push routing | Token platform is not ios, android, or web. |
push/missing-vapid-keys |
Web Push | Token metadata missing keys.p256dh or keys.auth. |
provider/no-route-for-push.<platform> |
Provider registry | No provider matches a token after the four-layer resolution. |
provider/fcm-legacy-server-key |
FCM | Config block contains the retired serverKey field. |
provider/cli-http2-required |
CLI HTTP executor | CLI invoked against an HTTP/2-only provider (APNs). |
provider/UnsupportedChannel |
Any provider | Provider received prepare_send for an unsupported channel. |
provider/UnsupportedOp |
Any provider | Provider received an op (batch / multi-channel / render) for which it returned UnsupportedOp. |
provider/NoProviderForChannel |
Provider registry | No registered provider supports the requested channel. |
provider/ConfigError |
Any provider | Provider config malformed or required fields missing at load time. |
validator/undefined-runtime-path |
Strict-mode validator | Expression path absent from runtime data object (distinct from “present and null”). |
validator/unknown-pipe |
Strict-mode validator | Pipe name not in KNOWN_PIPES. |
validator/invalid-pipe-arg |
Strict-mode validator | Pipe argument fails arity or type check against pipe_spec. |
The table above is the canonical reference for these runtime codes. Their producing subsystem (provider trait, route resolver, strict-mode validator) is named in the Source column.
Complete Rule Index
The full set of lint rules across every category. Severities here are normative — the rule reference inside each section may show shorter context, but this index is the source of truth that implementations MUST match.
| Rule ID | Severity | Category |
|---|---|---|
structure/missing-type-folder |
Warning | Structure |
structure/invalid-root-entry |
Error | Structure |
structure/invalid-name |
Error | Structure |
structure/too-deep |
Warning | Structure |
structure/duplicate-channel-format |
Error | Structure |
structure/no-channels |
Error | Structure |
structure/no-subdirectories |
Error | Structure |
structure/all-channels-disabled |
Warning | Structure |
structure/unrecognized-file |
Warning | Structure |
template/unknown-directive |
Error | Template |
template/undefined-variable |
Error | Template |
template/nullable-access |
Warning | Template |
template/raw-expression |
Warning | Template |
template/thematic-break |
Warning | Template |
template/directive-in-table |
Warning | Template |
template/inline-directive |
Warning | Template |
template/unknown-attr |
Error | Template |
template/unsupported-attr |
Error | Template |
template/invalid-attr-value |
Error | Template |
template/empty-directive-body |
Warning | Template |
template/action-without-link |
Warning | Template |
template/table-in-fields |
Warning | Template |
template/unknown-pipe |
Error | Template |
template/invalid-pipe-arg |
Error | Template |
template/unknown-style |
Error | Template |
template/unknown-color-token |
Error | Template |
template/unknown-bg-token |
Error | Template |
partial/not-found |
Error | Partial |
template/unknown-partial-param |
Warning | Partial |
template/missing-partial-param |
Info | Partial |
template/unresolved-partial-param |
Warning | Partial |
editor/metadata-wrong-context |
Error | Editor metadata |
a11y/alt-text-quality |
Warning | Accessibility |
a11y/alt-text-length |
Info | Accessibility |
a11y/image-only-link |
Warning | Accessibility |
a11y/link-text-is-url |
Warning | Accessibility |
a11y/table-missing-headers |
Warning | Accessibility |
a11y/empty-heading |
Warning | Accessibility |
a11y/excessive-emoji |
Info | Accessibility |
a11y/duplicate-heading-text |
Info | Accessibility |
a11y/adjacent-duplicate-links |
Info | Accessibility |
email/missing-subject |
Warning | |
email/missing-preheader |
Warning | |
email/missing-alt |
Warning | |
email/heading-hierarchy |
Warning | |
email/link-text-quality |
Warning | |
email/low-contrast |
Warning | |
email/url-shortener |
Warning | |
email/text-to-image-ratio |
Warning | |
email/size-limit |
Warning | |
email/subject-length |
Info | |
email/preheader-length |
Info | |
email/spam-words |
Info | |
email/subject-all-caps |
Info | |
email/subject-excessive-punctuation |
Info | |
email/all-caps-body |
Info | |
email/missing-unsubscribe |
Info | |
email/font-size-minimum |
Info | |
sms/no-images |
Warning | SMS |
sms/no-tables |
Warning | SMS |
sms/encoding-warning |
Info | SMS |
sms/extended-gsm-chars |
Info | SMS |
sms/message-length |
Info | SMS |
sms/segment-count |
Warning / Info | SMS |
push/missing-title |
Warning | Push |
push/title-length |
Warning | Push |
push/body-length |
Info | Push |
push/payload-size |
Warning | Push |
push/too-many-actions |
Warning | Push |
push/unknown-ios-key |
Warning | Push |
push/unknown-android-key |
Warning | Push |
push/unknown-web-key |
Warning | Push |
push/invalid-priority |
Error | Push |
push/invalid-interruption-level |
Error | Push |
whatsapp/too-many-buttons |
Warning | |
whatsapp/body-length |
Warning | |
whatsapp/header-length |
Warning | |
whatsapp/footer-length |
Warning | |
whatsapp/button-label-length |
Warning | |
whatsapp/cta-button-limit |
Warning | |
slack/block-count |
Warning | Slack |
slack/section-text-length |
Warning | Slack |
slack/actions-count |
Warning | Slack |
msteams/card-payload-size |
Warning | MS Teams |
msteams/action-count |
Warning | MS Teams |
brand/broken-ref |
Error | Brand |
brand/cyclic-ref |
Error | Brand |
brand/unknown-pipe |
Error | Brand |
brand/invalid-pipe-arg |
Error | Brand |
brand/bad-color |
Error | Brand |
brand/invalid-dimension |
Error | Brand |
brand/missing-primary |
Warning | Brand |
brand/missing-typography |
Warning | Brand |
brand/font-not-delivered |
Warning | Brand |
brand/orphaned-token |
Warning | Brand |
brand/unknown-element-type |
Warning | Brand |
Brand rules run once per project (attached to brand.yaml). The first six are resolver-derived — they fire when the brand graph fails to resolve (broken or cyclic {...} aliases, unknown color pipes, malformed colors/dimensions). The last five are token-quality lints. Detailed prose for each lives in brand.md § 11. Implementations consult crates/core/src/linter/brand.rs (or equivalent) to attach diagnostics to the brand.yaml path during a normal lint_all run.