fix(vale): reject a backreference in a swap key - #399
Conversation
|
@claude /review |
|
Claude finished @thecodedrift's task in 5m 17s —— View job Review completeReview mode: incremental — read 0 prior review thread(s) before reviewing.
AssessmentThis is a tight, well-evidenced change. The detector ( Two minor, non-functional findings, both comment-only:
Everything else checked out: the recipe's new "leading lookbehind" mirror, the |
| const { swap } = rule; | ||
| // Scoped to `substitution` deliberately, and the guard is not redundant with | ||
| // the field tables. `swap` is `substitution`'s field alone, so the strict | ||
| // union already rejects it on the other ten checks with an `E201` message — |
There was a problem hiding this comment.
[New] Minor: "the other ten checks" is off by one. VALE_CHECK_TYPES has 12 entries; excluding substitution leaves 11, of which 2 (consistency, spelling) are the permissive ones named in the very next clause, leaving 9 strict checks that reject swap via E201 — not ten. Doesn't affect behavior (comment only), but worth fixing given how measurement-precise the rest of this PR is.
No capture group survives a `swap` key. `NewSubstitution` compiles all of a rule's keys into one alternation, wrapping each in a capture group of its own because the index of the group that matched is how it recovers the replacement; any group the author wrote is rewritten to `(?:…)` by `convertCaptureGroups` first. Either way `\1` points at Vale's wrapper, the group still being matched, and matches nothing. Vale reports none of this: the rule loads, runs, writes nothing to stderr, and never fires. Measured against the vendored 3.22.0 binary, `(\w+) \1`, `(the) \1`, `the \1` and `(?:\w+) \1` are all silent as swap keys and all fire under `tokens` and `raw`. The detector is character-class aware because `[\1a]bc` was measured matching `abc`, where `\1` is an octal escape. Also corrects the mechanism the recipe and the vendor test gave for why lookaround and backreferences work at all. Vale compiles every pattern with regexp2 in RE2 mode unconditionally (internal/regex/regex.go:63); there is no Go-`regexp`-first path and no fallback. The conclusion was right, the explanation was not. `create-vale-rule` goes to v14, adds the `$1`-in-the-value asymmetry and the leading-lookbehind mirror of the trailing-lookahead limit, and the vendor contract now measures `consistency` and `conditional` so a widening is caught, not assumed.
2b32756 to
f1a232f
Compare
What
verifynow rejects a Valesubstitutionrule whoseswapkey carries a backreference. It is the first hard error the Vale style layer raises for a pattern-quality problem the binary itself accepts.The mechanism
A backreference in a
swapkey can never match, and Vale says nothing about it. The rule loads, runs on every document, writes nothing to stderr, and reports no finding. Measured against the vendored 3.22.0 binary:swapkeyswaptokens/raw(\w+) \1(the) \1the \1(?:\w+) \1the the(literal control)The cause is not the regex engine.
NewSubstitution(internal/check/substitution.go) compiles all of a rule's keys into one alternation, wrapping each key in a capture group of its own, because the index of the group that matched is how it recovers the replacement:Those wrappers have to be numbered 1..n, so any group the author wrote is rewritten away first:
Vale's guard only runs the rewrite when the key has a capturing
(. That is why all four forms above behave alike: with a group, it is converted and\1becomes the wrapper; without one, the rewrite is skipped and\1is the wrapper. Either way it is a self-reference to the group still being matched. The true rule is therefore not "a backreference does nothing inswap" but no capture group survives a swap key.The E201 evidence, and what it is not
There is no
E201here, and that is the point. Every existing hard error in this layer is justified by whole-run blast radius and its message says so: an unrecognised field or action name draws anE201, and Vale reads one config for the whole run, so a single bad key suppresses every other rule's findings; asequencewith notokenspanics with a Go stack trace and no findings at all. A swap backreference earns none of that. Vale's config parses,E201is not raised, stderr is empty, the exit status is clean, and every other rule keeps reporting normally.Why it is still a hard error
Owner's call, and the reasoning is "if it's broken in vale, we don't want it to be written as a rule."
The justification that the message has to carry itself: a rule that loads and never fires is indistinguishable from a clean project. A blast-radius failure is loud and gets fixed; this one is silent, and the author's own
fail/fixture is the only thing that could catch it — after they have already written the rule. So the message names the offending key, says the key can never match and that Vale reports nothing, and namesexistenceas the check to write instead.The measurement that justifies it
Migration cost is effectively zero. Across the seven published Vale style packages (Microsoft, Google, write-good, proselint, alex, Readability, Joblint) plus this repository's own rules: 161 rule files, 33 with a
swapmap, 1,502 swap keys, 0 carrying a live backreference.checkis unaffected, andtestalready fails a rule whosefail/fixture never fires.That near-zero cost is also what makes detector precision load-bearing: there is no suppression mechanism anywhere in the CLI, so a false positive leaves an author with no recourse at all.
The detector
Variant A, character-class aware. Since
convertCaptureGroupsonly ever replaces(, it can neither create nor destroy a\<digit>— so "apply the rewrite, then look for a survivor" collapses to one left-to-right scan for an unescaped\<1-9>outside a character class.Character-class awareness is mandatory, not polish: inside
[…]a\1is an octal escape, and[\1a]bcwas measured matchingabc.(\w+) \1(the) \1the \1(?:\w+) \1[\1a]bcabca\\1ba\1bq\(1\)zq(1)z(?:bull|ox)-likeox-likecolour(s?)→color$1colorsVariant B — flag a backreference only when the key has a group to convert — was rejected: it misses
the \1and(?:\w+) \1, both genuinely inert, and flags nothing extra on any real corpus.The rejection lives in the style layer, per rule, not in
vale-config.ts's config layer: a config-layer rejection aborts the whole run and hides every other rule's findings.The false mechanism claim, corrected
The recipe and
vale-vendor-contract.test.tsboth said Vale compiles a pattern with Go's ownregexpfirst and falls back toregexp2when that engine refuses it. There is no such path.Compileininternal/regex/regex.go:63is:regexp2 in RE2 compatibility mode, unconditionally. The user-facing conclusion (lookaround and backreferences are available) is unchanged; the explanation was wrong, and a wrong explanation is what made "but not in
swap" look like an engine quirk rather than asubstitution-only compilation detail.Also in this PR
create-vale-rule→ topic v14. The constraint is generalised to "no capture group survives a swap key" with all four measured forms;$1in the swap value is documented as working (Vale expands it against the original, unconverted key insubMsg);\1in a character class is documented as fine; and the leading-lookbehind mirror is added — the recipe documented only the trailing-lookahead direction, but(?<=x)foois equally silent undertokensandswaponxfooand fires underraw.consistencyandconditional, so a widening of the blast radius is caught rather than assumed. Both were measured unaffected:consistencywraps eacheitherkey in a named group and regexp2 numbers named groups after unnamed ones, so the author's\1still points at the author's group;conditionalcompilesfirstandseconddirectly with no wrapping. (A first pass suggested both were broken too — that was a YAML double-quote escaping artifact in the probe, not Vale.)cli-rule-validation, "The schema may reject a pattern the binary accepts only when the pattern can never fire". No standing requirement is restated or renamed. Dry-run archive verified purely additive: 37 → 42 scenarios, 7 → 8 requirements, 0 dropped. Archived on this PR, which is the tip.Gates
pnpm build,pnpm typecheck,pnpm lint(eslint +cli check: no issues),pnpm test— 1719 passed, 102 files, 0 failed.Upstream issue draft — NOT FILED
This has not been filed and should not be filed as-is. The owner declined for now: "no. I don't trust you and I'd want to create a standalone reproduction repo."
Before anyone files this, it needs a standalone reproduction repository built from scratch — an independent Vale config, rules and fixtures, with a Vale binary obtained directly from upstream releases rather than our vendored
@taskless/vale-*package. The report has to stand on its own, so that a maintainer can reproduce it without taking anything about our scaffolding on trust. Nothing below has been verified outside this repository.Draft text follows, for copy-paste if and when that exists:
Fixes #392