You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
# Exist.yml -- control: existence, no scope declaredextends: existencemessage: "EXIST found '%s'"level: errortokens:
- connection pool
# ExistSentence.yml -- control: existence, pinned to sentenceextends: existencemessage: "EXISTSENT found '%s'"level: errorscope: sentencetokens:
- 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 -nt.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:
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:
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.
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:
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:
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.
Environment
391e3c0bfrom source)vale 3.16.0); plusgo build ./cmd/valeat391e3c0bfor the source comparisonSummary
A
sequencerule 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 whateverscopethe author writes, without an error or a warning.sequenceis 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
existencerule does in the same run — the closest thing to a baseline:existence(baseline)sequence, no scopesequence,scope: [list, paragraph, heading]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 twosequencecolumns are identical.I expected one of two things: that Vale would honour
scopeon asequencerule, or — ifsequencegenuinely cannot support those scopes — that writing one would error rather than silently do nothing.Minimal reproduction
.vale.inistyles/T/S.ymls.mdThree 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 instyles/T/:t.mdReading that output:
T.Existfires on all six blocks (lines 1, 3, 5, 7, 9, 13).T.ExistSentencefires only on the paragraph (line 3) — sosentence.*blocks appear to exist only for paragraphs.T.Seqfires only on the paragraph.T.SeqScopedfires only on the paragraph too — identical to the rule that declared no scope at all, even though it asked forlistandheading.That last line is what makes me think Vale never uses the declared scope.
Renaming the file to
.txtmakes the same rule match both linesThis one seems like the clearest signal. Exactly the minimal reproduction above — same rule, same bytes — with the
.vale.inisection widened to[*.{md,txt}]and the file copied tos.txt:As
.txtboth lines match; as.mdonly the paragraph does. Plain text goes throughlintTxt, which callslintProseon 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:
internal/check/sequence.go:187—NewSequenceends with:an unconditional assignment, so every
sequencerule ends up scoped tosentenceregardless 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:So Vale parses a
sequencerule'sscope, rejects it if malformed (checkScopeserrors on inline scopes and on scopes containing spaces) — and then overwrites it. Unknown top-level keys are a hard error viaErrorUnusedindecodeRule(definition.go:455). So Vale cannot rejectscopeas unsupported either: it is an accepted, validated key with no effect.internal/lint/ast.go:194— inlintScope, the loop overstate.tagHistoryreturns early vialintBlockfor anything intagToScope(li,td,th,caption,blockquote,figcaption) or matchingheading. Only the fall-through — the paragraph case — reacheslintProse:lintProseis what callsf.NLP.Compute, andsentence.*blocks are created ininternal/nlp/provider.go:174-184.grep -rn lintProse internal/gives only two call sites:ast.go:194andlint.go:260(lintTxt).Net effect, as far as I can tell: (1) pins every
sequencerule tosentence, and (2) leavessentenceblocks existing only for paragraphs. Sosequenceonly ever runs on paragraphs, and noscope:a user writes widens it.I checked this by building
391e3c0btwice: once as-is, once with the assignment atsequence.go:187made conditional on the author not having supplied a scope. On the wider fixture above,T.SeqScopedwent 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
sequencerule matches in prose and not in a list item and they say they don't know why."sequence" extension point not working with .txt(closed 2023-02-11), looks like the same class of bug in a different place —sequenceunreachable becauselintProsewasn't on that code path — and was fixed in6832233d, "fix: createlintTxt", by routing plain text throughlintProse. 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:
scope:on asequencerule produces no error, no warning, and no observable effect. It validates, so it looks accepted.sequencerule that matches nothing is indistinguishable from a document with nothing to flag: exit status 0, no diagnostics.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.patterninstead ofupos, addingskip) — no change; the token set isn't the variable, the block type is.SkippedScopes: the config above sets none, and the controlexistencerule matches all six blocks in the same run..txt.--ignore-syntaxis 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:187may not be enough on its own.manager.go:170-172already defaults the scope beforeNewSequenceruns: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 "explicitlytext" looks like part of the work.Also worth noting: the existing test suite can't detect any of this.
testdata/fixtures/checks/Sequence/test.mdis paragraphs only, and the whole Go suite plus everytestdata/fixtures/scopes/*fixture produced byte-identical output between my patched and unpatched builds.