Skip to content

sequence rules never fire outside paragraphs: the declared scope is silently replaced with sentence #1124

Description

@wasaga

Environment

  • Vale version: 3.16.0 (also reproduced by building 391e3c0b from source)
  • Operating system: macOS 26.5.1, arm64
  • Installation method: Homebrew (vale 3.16.0); plus go build ./cmd/vale at 391e3c0b for the source comparison

Summary

A sequence rule matches a sentence in a paragraph. It silently misses the identical sentence in a list item, heading, table cell or blockquote. Vale appears to discard whatever scope the author writes, without an error or a warning.

sequence is the only extension point that can reach part-of-speech data. So POS-based rules cannot reach list items at all — which, in procedural documentation, is where most of the prose lives.

Expected vs. actual

The rule below matches four adjacent nominal tokens. It is an arbitrary probe, chosen only because it needs part-of-speech data and fires reliably. The same sentence appears in each block type.

"Expected" is what an equivalent existence rule does in the same run — the closest thing to a baseline:

Block existence (baseline) sequence, no scope sequence, scope: [list, paragraph, heading]
paragraph match match match
ordered list item match no match no match
unordered list item match no match no match
heading match no match no match
blockquote match no match n/a — not requested
table cell match no match n/a — not requested

So the content is reaching Vale; it just isn't reaching sequence. And the third column is what I'd most like to flag: declaring a scope changes nothing, so the two sequence columns are identical.

I expected one of two things: that Vale would honour scope on a sequence rule, or — if sequence genuinely cannot support those scopes — that writing one would error rather than silently do nothing.

Minimal reproduction

.vale.ini

StylesPath = styles
MinAlertLevel = suggestion

[*.md]
BasedOnStyles = T

styles/T/S.yml

extends: sequence
message: "Sequence matched '%s'."
level: error
tokens:
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ

s.md

Update the default database connection pool size limit.

1. Update the default database connection pool size limit.
$ vale --output=line s.md
s.md:1:12:T.S:Sequence matched 'default'.
s.md:1:20:T.S:Sequence matched 'database'.
s.md:1:29:T.S:Sequence matched 'connection'.

Three alerts on line 1. Zero on line 3, for a byte-identical sentence.

Wider version, showing every block type and a control rule

Same .vale.ini, with four rules in styles/T/:

# Seq.yml -- sequence, no scope declared
extends: sequence
message: "SEQ matched '%s'."
level: error
tokens:
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
# SeqScoped.yml -- sequence, scope declared explicitly
extends: sequence
message: "SEQSCOPED matched '%s'."
level: error
scope:
  - list
  - paragraph
  - heading
tokens:
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
  - upos: NOUN|ADJ
# Exist.yml -- control: existence, no scope declared
extends: existence
message: "EXIST found '%s'"
level: error
tokens:
  - connection pool
# ExistSentence.yml -- control: existence, pinned to sentence
extends: existence
message: "EXISTSENT found '%s'"
level: error
scope: sentence
tokens:
  - connection pool

t.md

# Update the default database connection pool size limit

Update the default database connection pool size limit.

1. Update the default database connection pool size limit.

- Update the default database connection pool size limit.

> Update the default database connection pool size limit.

| Col |
| --- |
| Update the default database connection pool size limit. |
$ vale --output=line t.md | sort -t: -k2 -n
t.md:1:31:T.Exist:EXIST found 'connection pool'
t.md:3:12:T.Seq:SEQ matched 'default'.
t.md:3:12:T.SeqScoped:SEQSCOPED matched 'default'.
t.md:3:20:T.Seq:SEQ matched 'database'.
t.md:3:20:T.SeqScoped:SEQSCOPED matched 'database'.
t.md:3:29:T.Exist:EXIST found 'connection pool'
t.md:3:29:T.ExistSentence:EXISTSENT found 'connection pool'
t.md:3:29:T.Seq:SEQ matched 'connection'.
t.md:3:29:T.SeqScoped:SEQSCOPED matched 'connection'.
t.md:5:32:T.Exist:EXIST found 'connection pool'
t.md:7:31:T.Exist:EXIST found 'connection pool'
t.md:9:31:T.Exist:EXIST found 'connection pool'
t.md:13:31:T.Exist:EXIST found 'connection pool'

Reading that output:

  • T.Exist fires on all six blocks (lines 1, 3, 5, 7, 9, 13).
  • T.ExistSentence fires only on the paragraph (line 3) — so sentence.* blocks appear to exist only for paragraphs.
  • T.Seq fires only on the paragraph.
  • T.SeqScoped fires only on the paragraph too — identical to the rule that declared no scope at all, even though it asked for list and heading.

That last line is what makes me think Vale never uses the declared scope.

Renaming the file to .txt makes the same rule match both lines

This one seems like the clearest signal. Exactly the minimal reproduction above — same rule, same bytes — with the .vale.ini section widened to [*.{md,txt}] and the file copied to s.txt:

$ vale --output=line s.md
s.md:1:12:T.S:Sequence matched 'default'.
s.md:1:20:T.S:Sequence matched 'database'.
s.md:1:29:T.S:Sequence matched 'connection'.

$ vale --output=line s.txt
s.txt:1:12:T.S:Sequence matched 'default'.
s.txt:1:20:T.S:Sequence matched 'database'.
s.txt:1:29:T.S:Sequence matched 'connection'.
s.txt:3:15:T.S:Sequence matched 'default'.
s.txt:3:23:T.S:Sequence matched 'database'.
s.txt:3:32:T.S:Sequence matched 'connection'.

As .txt both lines match; as .md only the paragraph does. Plain text goes through lintTxt, which calls lintProse on the whole file. There is no markup structure to route the list item away from the prose path.

What I think the mechanism is

Hedging here — I'm a user reading the source, not proposing a fix. Two lines look like they combine:

  1. internal/check/sequence.go:187NewSequence ends with:

    rule.Definition.Scope = []string{"sentence"}

    an unconditional assignment, so every sequence rule ends up scoped to sentence regardless of what its author wrote.

    What makes this look accidental rather than intended: the same function already validates that field 88 lines earlier, at sequence.go:99:

    err = checkScopes(rule.Scope, path)

    So Vale parses a sequence rule's scope, rejects it if malformed (checkScopes errors on inline scopes and on scopes containing spaces) — and then overwrites it. Unknown top-level keys are a hard error via ErrorUnused in decodeRule (definition.go:455). So Vale cannot reject scope as unsupported either: it is an accepted, validated key with no effect.

  2. internal/lint/ast.go:194 — in lintScope, the loop over state.tagHistory returns early via lintBlock for anything in tagToScope (li, td, th, caption, blockquote, figcaption) or matching heading. Only the fall-through — the paragraph case — reaches lintProse:

    b := state.block(txt, "text"+l.metaScope+f.RealExt)
    return l.lintProse(f, b, state.lines)

    lintProse is what calls f.NLP.Compute, and sentence.* blocks are created in internal/nlp/provider.go:174-184. grep -rn lintProse internal/ gives only two call sites: ast.go:194 and lint.go:260 (lintTxt).

Net effect, as far as I can tell: (1) pins every sequence rule to sentence, and (2) leaves sentence blocks existing only for paragraphs. So sequence only ever runs on paragraphs, and no scope: a user writes widens it.

I checked this by building 391e3c0b twice: once as-is, once with the assignment at sequence.go:187 made conditional on the author not having supplied a scope. On the wider fixture above, T.SeqScoped went from 3 alerts (paragraph only) to 12 — heading, paragraph and both list items, correctly still excluding the blockquote and table cell it did not ask for. I am not proposing that exact change as the fix; see the caveat below.

Prior art

  • Discussion #1043, "How to use NLP on lists in AsciiDoc" (2025-09-30), is this same symptom, still with no replies. The reporter's sequence rule matches in prose and not in a list item and they say they don't know why.
  • Issue "sequence" extension point not working with .txt #549, "sequence" extension point not working with .txt (closed 2023-02-11), looks like the same class of bug in a different place — sequence unreachable because lintProse wasn't on that code path — and was fixed in 6832233d, "fix: create lintTxt", by routing plain text through lintProse. That's the precedent for treating this as a bug; the markup-block gap just wasn't covered by that change.

The silent part, which is the part that costs time

Nothing tells you any of this:

  • Writing scope: on a sequence rule produces no error, no warning, and no observable effect. It validates, so it looks accepted.
  • A sequence rule that matches nothing is indistinguishable from a document with nothing to flag: exit status 0, no diagnostics.
  • Because it does work in paragraphs, the natural conclusion when a rule misses a list item is that the POS tagging is wrong for that sentence, so the time goes into rewriting tokens and tags. Discussion #1043 and #557 both read that way.

A rule that quietly matches a subset of the document is worse than one that errors, because CI stays green.

What I tried that didn't work

  • scope: list, scope: [list, paragraph, heading], scope: sentence.list, scope: text.list — all four give output identical to declaring no scope at all: the paragraph match only, exit 1, no diagnostic about the scope.
  • Restructuring the tokens (pattern instead of upos, adding skip) — no change; the token set isn't the variable, the block type is.
  • Confirming it isn't SkippedScopes: the config above sets none, and the control existence rule matches all six blocks in the same run.
  • Confirming it isn't POS tagging: the failing sentence is byte-identical to the passing one, and it matches as a paragraph — or when I rename the file to .txt.
  • --ignore-syntax is not a workaround either: on the reproduction above it reports zero alerts, losing even the paragraph match that works without the flag.

A caveat on the obvious fix

Just deleting sequence.go:187 may not be enough on its own. manager.go:170-172 already defaults the scope before NewSequence runs:

if scope, ok := generic["scope"]; scope == nil || !ok {
    generic["scope"] = []string{"text"}
}

so by line 187 the scope is never empty — it is either the author's value or ["text"], and the two are no longer distinguishable. When I made that assignment conditional on emptiness, rules declaring no scope changed from paragraph-only to matching everywhere. That is a real behaviour change for existing styles, so telling "unset" from "explicitly text" looks like part of the work.

Also worth noting: the existing test suite can't detect any of this. testdata/fixtures/checks/Sequence/test.md is paragraphs only, and the whole Go suite plus every testdata/fixtures/scopes/* fixture produced byte-identical output between my patched and unpatched builds.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions