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.
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 are grouped by file and channel.
The linter runs in three phases per channel template:
-
Parse phase — The template text is parsed into an AST. Parse failures produce a
parse/error(Error) diagnostic and 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 run here: email HTML size, SMS segment count, Slack block count, push payload size, and Teams card size.
Structure rules run once per notification bundle, before any template is parsed.
Theme-level contrast checks 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 |
Warning | A {{path}} expression references a variable not declared in data.schema.yaml |
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/unknown-attr |
Warning | An unrecognised key appears in a {key="value"} attribute block |
template/unsupported-attr |
Warning | A recognised attribute is used on a directive or channel that does not support it |
template/invalid-attr-value |
Warning | An attribute value is not in the allowed set for that key |
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/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: Warning
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/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/unknown-attr
Severity: Warning
The recognised block attribute keys are: align, bg, bg-image, 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.
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 email theme — brandColor,
buttonColor, bodyColor, headingColor, and related keys from
norq.config.yaml. Per-directive colour overrides are checked where they can
be statically resolved.
Fix: adjust theme colour keys in norq.config.yaml 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/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.
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.
Complete Rule Index
The full set of 71 rules across all categories:
| 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 |
Warning | 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 |
Warning | Template |
template/unsupported-attr |
Warning | Template |
template/invalid-attr-value |
Warning | Template |
template/empty-directive-body |
Warning | Template |
template/action-without-link |
Warning | Template |
template/table-in-fields |
Warning | Template |
partial/not-found |
Error | Partial |
template/unknown-partial-param |
Warning | Partial |
template/missing-partial-param |
Info | Partial |
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 |
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 |