headwater-check 0.4.0

Generates the rules from the taxonomy, runs them, computes coverage against the census, and keys each instance on what it read and on the clock it was handed
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
// SPDX-License-Identifier: Apache-2.0
//! A Document-origin check: the constructions a voice regime forbids.
//!
//! [Spec 12](../../../../docs/spec/12-check-layer.md#the-five-origins-of-a-check)
//! puts voice first among the Document-origin examples and says what separates
//! that origin from the two generated ones: "Document checks are the ones that
//! no graph standard can reach, because the body is not in the graph."
//!
//! # What is data here, and what is not
//!
//! The regime is data. Which kinds answer to it is data, and so is the list of
//! categories it forbids: this file names no kind and no category, and a
//! taxonomy that forbids one more produces one more finding with no code.
//!
//! The **patterns** are not data, and the meta-schema says why. `voice_regime`
//! carries one member, `forbid: {seq: {scalar: string}}`, with a comment
//! marking it a gap: "spec 3 names the forbidden constructions in prose and no
//! value set states them." So the names are an open set in the language and a
//! closed set in this engine, and the two disagree by construction.
//!
//! A category this engine has no pattern set for is therefore not ignored. The
//! instance **skips with a reason that names the category**, which is the same
//! posture [`crate::participation`] takes toward a window it cannot read, and
//! it is what keeps the gap visible per document rather than per release.
//!
//! # The patterns read author-owned sentences and nothing else
//!
//! [Spec 3](../../../../docs/spec/03-authoring-and-lifecycle.md#what-a-lexical-rule-gets-wrong-and-where-posture-comes-from)
//! rules that "a quotation, a code span, a citation line, and a generated block
//! are outside every voice rule by construction. This is not an exemption that
//! a rule declares." Nothing below declares one:
//! [`headwater_doc::Sentence::authored`] is the text the parser says this
//! author wrote as prose, and it is the only string these patterns see.
//!
//! # Severity, and why no fix is offered
//!
//! Advisory, permanently, and the reason is fixability rather than precision.
//! [Spec 3](../../../../docs/spec/03-authoring-and-lifecycle.md#voice) states it
//! after Q5 measured the alternative: "a category may become blocking only when
//! its remediation is mechanical and total ... A category whose remediation is
//! a rewrite stays advisory permanently, whatever its false-positive rate turns
//! out to be." Every category here is a rewrite. Spec 3 names phased-rollout
//! language as the most likely candidate for a mechanical fix, and adds that
//! nobody has built one. That is still true.
//!
//! # This rule is half of Q5's instrument
//!
//! [Q5](../../../../docs/spec/09-decisions.md#q5--voice-checking-depth) closed on
//! a measurement of three ASD-STE100 structural rules, used as proxies, and
//! [spec 13](../../../../docs/spec/13-open-obligations.md) records what stayed
//! unmeasured: nobody has measured `future_intent`, `change_narration` or
//! `phased_rollout`, "because no implementation of them exists". This file is
//! that implementation. The other half is a human campaign, and no code
//! discharges it: an adjudicated sample of at least 50 findings per category,
//! under the two labels spec 4 declares.
//!
//! # Two of the three categories stand at saturation, and one reports a false positive
//!
//! Read on `0ee88a2d` on 2026-09-27, over the body and the `summary` facet of
//! each document, which is the population edition four reads. The count is a
//! word-boundary search for every pattern, with front matter, code blocks and
//! code spans taken out. **`future_intent` occurs on 2 of its 14 patterns over
//! the 378 documents whose kind binds `declarative`, and `phased_rollout` on 0
//! of its 13 over the 382 that bind `declarative` or `prospective`.** The two
//! denominators are the kind-to-regime binding of `.headwater/taxonomy.lock`
//! applied to the 421 documents of `.headwater/export.json`, of which 39 bind
//! `narrative`. `change_narration` occurs on 5 of its 23 patterns over the
//! same 382.
//!
//! Of the two `future_intent` patterns, `will be` occurs twice, and both
//! occurrences sit inside a quotation in one doctrine page, so
//! `Sentence::authored` never gives them to the rule. The other is
//! `will eventually`, the one finding the category reports, and it is false:
//! `the_rulings_of_2026_09_27_still_match` holds the sentence and names its
//! mode. So neither category reports a fault that the corpus holds. Both sets
//! still stand where edition one of `change_narration` stood, and no run
//! separates the two readings:
//! [HW-OBL-0168](../../../../docs/obligations/0168-a-saturated-pattern-set-and-a-clean-corpus-are-the-same-zero-and-no-report-separates-them.md)
//! holds the coverage line that would, and it is open.

use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use headwater_doc::Sentence;

pub const RULE: &str = "voice.forbidden_construction";

/// One forbidden category, and how it reads in prose.
struct Category {
    /// The name a regime writes.
    name: &'static str,
    /// What a reader is asked to write instead. One sentence, because it is
    /// the whole of the remediation a rewrite can be told in advance.
    instead: &'static str,
    /// The curated pattern set. Each entry is a lower-case substring of an
    /// author-owned sentence, matched on word boundaries.
    patterns: &'static [&'static str],
}

/// The categories this engine knows, and the only ones it can report on.
///
/// The set is closed here and open in the language. See the module comment for
/// why the two differ and what an instance does when it meets the difference.
///
/// Each pattern set is curated rather than derived, which is
/// [Q5](../../../../docs/spec/09-decisions.md#q5--voice-checking-depth)'s own
/// ruling: "the curated pattern set, the per-category posture and the reasoned
/// escape hatch all stand". The sets are small on purpose. Q5 measured where a
/// lexical checker's errors come from, and the lexicon produced approximately
/// none of them, so a set that stays inside what the words plainly mean keeps
/// that property.
const CATEGORIES: [Category; 3] = [
    Category {
        name: "future_intent",
        instead:
            "state what the system does now, or move the sentence to a document whose kind narrates",
        patterns: &[
            "will be",
            "will become",
            "will then",
            "will eventually",
            "will later",
            "is planned",
            "are planned",
            "we plan to",
            "in the future",
            "at a later date",
            "to be added",
            "to be decided",
            "coming soon",
            "for a future release",
        ],
    },
    Category {
        name: "change_narration",
        instead: "state the position that holds, and leave the change to the decision that made it",
        // Two tiers, and the tier is a record of curation rather than a
        // severity. Spec 3 rules that "posture per category comes from
        // fixability and never from precision", so a less precise pattern
        // reports at the same weight as a precise one or it does not ship.
        //
        // Tier one was measured against this corpus and every instance of each
        // entry was change narration. Tier two plainly means a change in
        // general English, which is Q5's test for membership, and it is where
        // every false positive of this category sits.
        //
        // **The measured rate of `no longer`, read on `58f46a8d` on
        // 2026-09-11.** The category reports 36 findings over this corpus and
        // 24 of them are `no longer`. All 36 were adjudicated one at a time:
        // 13 are genuine, 5 stand where narration is the content a reader
        // wants, and 18 are false in a way that no rewrite repairs. So
        // `no longer` is 9 genuine of 24, which is 37.5 per cent, and the
        // category is 13 of 36.
        //
        // **`no longer` stays, and the argument is the absolute count rather
        // than the rate.** Removing it removes 9 of the 13 genuine findings of
        // the whole category and leaves 4. The rate moves in both directions
        // and settles nothing on its own: over genuine findings alone it falls
        // from 13 of 36 to 4 of 12, and over the findings that are not
        // permanently false it rises from 18 of 36 to 9 of 12. What does not
        // move is the yield, because 9 genuine findings go and no numerator
        // returns them. Q5 sets membership at a phrase that plainly means a
        // change in general English, and this one meets it. Spec 3 rules that
        // posture comes from fixability and never from precision, this
        // category is advisory already, and nothing in the corpus states a
        // precision floor for membership. What reopens the ruling is the trend and not the
        // rate: each rewrite that clears a genuine finding leaves the false
        // residual in place, so a later reading that finds the genuine count
        // at zero with the false count unmoved is the reading that retires the
        // pattern.
        //
        // **Re-read on `0ee88a2d` on 2026-09-27, and the trend is moving.**
        // The category reports 22 findings, and 10 of them are `no longer`.
        // Of those 10, 1 is genuine and 9 are false. Since 2026-09-11 the
        // genuine count fell from 9 to 1, and the false count fell too: the
        // quotation exclusion of #783 removed one, and rewrites made for other
        // reasons reworded others. So "unmoved" above needs a baseline, and
        // this reading is it: 1 genuine and 9 false. A later reading compares
        // its false count with 9, not with the figures of 2026-09-11. The
        // pattern stays until a reading finds the genuine count at zero.
        //
        // **No regime can name the sections it reads, and a `## Context`
        // narration takes no directive.** A decision record's `## Context` is
        // where it carries history, and the rule reads it as it reads any other
        // section. On `0ee88a2d`, 4 of the 23 findings of this rule stand in a
        // `## Context` section: 3 are narration that a reader wants there, and
        // 1 is a false positive of the definition mode. A section scope clears
        // those 4 and misses the fourth legitimate narration, which stands in a
        // `## Consequences` section, so it prices a schema change at 4 findings.
        // The convention an author may use instead is a block directive on the
        // sentence, `headwater allow=voice.forbidden_construction scope=block
        // reason=accepted_deviation`, with an `until` and a note. It costs 4
        // directives today, and none is spent: an `until` buys a
        // re-adjudication of a sentence that never becomes wrong, which is the
        // argument against a directive on the false positives below as well.
        // Two cases hold the ruling. `the_rulings_of_2026_09_27_still_match`
        // holds the parse half, and
        // `the_voice_rule_reads_a_context_section_like_any_other` in
        // `tests/fixtures.rs` holds the whole run, so a section scope added to
        // `evaluate` fails it.
        //
        // The 18 false positives are five ways that English states something
        // other than a change, and `the_measured_false_positives_still_match`
        // holds one sentence of each. None of them carries a `headwater allow`
        // directive: a directive requires an `until`, and none of these
        // sentences becomes wrong.
        //
        // Three candidates were measured and rejected, and the reason each one
        // failed is why this set is curated against a corpus and not from
        // intuition. `the retired` matched four sentences and none was a
        // narration: "retired term" and "retired phrase" are this system's own
        // vocabulary. `at one point` matched the positional sense, "worth
        // making at one point in a text". `was replaced` matched three
        // sentences that describe a measurement procedure, "every inline
        // quotation was replaced by one word".
        patterns: &[
            // Tier one.
            "we moved from",
            "we changed",
            "we renamed",
            "we replaced",
            "used to be",
            "used to have",
            "used to",
            "was renamed",
            "were renamed",
            "has been renamed",
            "as before",
            "unlike before",
            "in the old",
            "the previous version",
            "an earlier version",
            "there was no",
            "there were no",
            "did not exist",
            "has since",
            "formerly",
            // Tier two.
            "no longer",
            "now that",
            "previously",
        ],
    },
    Category {
        name: "phased_rollout",
        instead: "state what holds, and record the sequence in the plan that owns it",
        patterns: &[
            "phase one",
            "phase two",
            "first phase",
            "second phase",
            "in a later phase",
            "for now",
            "at first",
            "to begin with",
            "in the first release",
            "in a later release",
            "rolls out",
            "rolled out in",
            "rollout of",
        ],
    },
];

/// The check, generated from the voice regimes and the kinds that bind them.
pub struct Voice {
    /// Each kind, and what its regime forbids: the categories this engine can
    /// report, and the names it cannot. Computed once, because the generation
    /// step reads the taxonomy and the evaluation step reads one document.
    bound: Vec<Bound>,
    /// The facet in the `scent` role, resolved once from the shape. A
    /// forbidden construction is forbidden wherever this document's author
    /// wrote it, and [`crate::frontmatter`] states why the population is a
    /// role.
    scent: Option<String>,
}

struct Bound {
    kind: String,
    regime: String,
    /// Indices into [`CATEGORIES`], in the order the regime declares them.
    known: Vec<usize>,
    /// The names the regime forbids that this engine has no pattern set for.
    unknown: Vec<String>,
}

impl Voice {
    /// The generation step, in full.
    pub fn over(shape: &Shape) -> Self {
        let mut bound = Vec::new();
        for kind in &shape.kinds {
            let Some(regime) = shape.voice_of(&kind.name) else {
                continue;
            };
            if regime.forbid.is_empty() {
                continue;
            }
            let mut known = Vec::new();
            let mut unknown = Vec::new();
            for name in &regime.forbid {
                match CATEGORIES
                    .iter()
                    .position(|category| category.name == name.as_str())
                {
                    Some(index) => known.push(index),
                    None => unknown.push(name.clone()),
                }
            }
            bound.push(Bound {
                kind: kind.name.clone(),
                regime: regime.name.clone(),
                known,
                unknown,
            });
        }
        Voice {
            bound,
            scent: crate::frontmatter::scent_facet(shape),
        }
    }

    fn bound_to(&self, kind: &str) -> Option<&Bound> {
        self.bound.iter().find(|bound| bound.kind == kind)
    }
}

impl DocumentCheck for Voice {
    const RULE: &'static str = self::RULE;
    /// Raise this when a pattern set changes, because a changed pattern set is
    /// a changed verdict and the cache holds the old one ([`crate::cache`]).
    ///
    /// Edition two widens `change_narration`. Edition one reported nothing over
    /// this corpus on 258 instances, and not one of its forty patterns occurred
    /// in a declarative document, so the set was saturated rather than
    /// satisfied.
    ///
    /// **The measurement of 2026-09-11 moved no pattern, so this number stands
    /// at 2, and the evidence is the source rather than a run.** The 209 lines
    /// above the test module that are neither blank nor a comment are
    /// identical to those of `58f46a8d`: no pattern, no category entry and no
    /// line of the check body moved. So a cached verdict cannot differ from a
    /// fresh one, and the divergence this constant guards against is
    /// unrepresentable here rather than merely unobserved. Raising the number
    /// would discard every cached voice verdict in every clone and assert a
    /// change that no run made.
    ///
    /// **Edition three, on 2026-09-11.** #783 moved the inline quotation out of
    /// this rule's population. The parse now marks a quotation inside a
    /// sentence as another author's, so `Sentence::authored` drops it and this
    /// rule never receives it. No pattern here moved, and that is exactly why
    /// the number has to: the document, the lock and the rule are all
    /// unchanged, so a warm cache from the previous engine would serve the old
    /// verdict on every document that quotes anybody and the change would read
    /// as working while it did nothing.
    ///
    /// **Edition four, on 2026-09-23 ([#774](https://github.com/headwater-ai/headwater/issues/774)).**
    /// This rule now also reads the facet in the `scent` role, exactly as
    /// [`crate::retired`] and [`crate::language`] already do. The population
    /// widens from the body alone to the body and that facet, so a warm cache
    /// from before this edition would keep serving edition three's verdict on
    /// every document whose `scent` facet writes a forbidden construction,
    /// which is the defect this issue exists to fix. The same edition also
    /// changes the no-prose posture from a silent `Outcome::Passed` to a
    /// written `Outcome::Skipped`, which is not itself cache-relevant (a skip
    /// and a pass with zero findings differ in the report, not in the
    /// findings a subsequent read would compare) but is bundled into the same
    /// edition because it is the same fix, made at the source #774 names.
    ///
    /// **Edition five, on 2026-09-26 ([#1151](https://github.com/headwater-ai/headwater/issues/1151)).**
    /// The splitter in `headwater-doc` now opens a sentence at a name whose
    /// shape says it is a name, such as `n8n` or `macOS`, and at an issue
    /// reference such as `#791`. Before, each of these merged into the sentence
    /// before it. This rule reports per sentence, so the span and the count of its findings move with the split. No pattern here moved, and the document, the lock and
    /// the rule are all unchanged, so a warm cache from the previous engine
    /// would serve the merged verdict and the fix would read as working while
    /// it did nothing.
    const VERSION: u32 = 5;
    /// The body, because the regime is about prose. This declaration is the
    /// access: without it [`DocumentView::body`] returns nothing.
    const NEEDS_BODY: bool = true;

    /// A kind whose chain binds no voice regime, or one that forbids nothing,
    /// generates no instance. The narrative regime is the second case: spec 3
    /// makes evidence documents "time-bound by nature, and exempt", and an
    /// instance over one would count it as checked by a rule with nothing to
    /// check.
    fn instantiates(&self, kind: &str) -> bool {
        self.bound_to(kind).is_some()
    }

    fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
        let Some(bound) = self.bound_to(view.kind()) else {
            return Outcome::Passed;
        };
        // A regime whose every category is outside this engine's set decides
        // nothing, and says so. A regime with one readable category reports on
        // that one: a skip would lose the finding, and a silent pass would
        // claim the unreadable categories held.
        if bound.known.is_empty() {
            return Outcome::Skipped(format!(
                "`{}` forbids {}, and this engine has no pattern set for {}",
                bound.regime,
                bound.unknown.join(", "),
                match bound.unknown.len() {
                    1 => "it",
                    _ => "any of them",
                }
            ));
        }
        let body = view.body();
        let scent = self
            .scent
            .as_deref()
            .and_then(|facet| crate::frontmatter::Scent::of(view, facet));

        // Body sentences and the scent facet's sentences, in one population.
        // `in_body` says which one a given sentence came from, and where a
        // finding sourced from it anchors: at its own span for a body
        // sentence, at the start of the value the author wrote for a facet
        // sentence (`crate::frontmatter` states why).
        let prose = body
            .into_iter()
            .flat_map(|body| body.sentences().into_iter().map(|s| (s, true)))
            .chain(
                scent
                    .iter()
                    .flat_map(|scent| scent.sentences.iter().cloned().map(|s| (s, false))),
            )
            .collect::<Vec<_>>();

        if prose.is_empty() {
            // A regime with a readable category and nothing to read it
            // against: a skip would lose the finding, and a silent pass would
            // claim a check that never ran.
            return Outcome::Skipped(format!(
                "`{}` binds this kind and this document carries no prose to read: no body and no `{}` facet with a value",
                bound.regime,
                self.scent.as_deref().unwrap_or("scent")
            ));
        }

        let mut findings = Vec::new();
        for index in &bound.known {
            let category = &CATEGORIES[*index];
            for (sentence, in_body) in &prose {
                let Some(pattern) = matched(category, sentence) else {
                    continue;
                };
                let (line, column, subject) = match (in_body, &scent) {
                    (true, _) => (
                        sentence.span.start.line,
                        sentence.span.start.col,
                        "this sentence".to_string(),
                    ),
                    (false, Some(scent)) => (
                        scent.span.start.line,
                        scent.span.start.col,
                        format!("the `{}` facet", scent.facet),
                    ),
                    // Unreachable: a sentence not from the body comes from
                    // the scent, and `scent` is `Some` whenever one of its
                    // sentences reached this loop.
                    (false, None) => (0, 0, "this sentence".to_string()),
                };
                findings.push(Finding {
                    rule: self::RULE,
                    severity: Severity::Warn,
                    obligation: None,
                    path: view.path().to_string(),
                    line,
                    column,
                    message: format!(
                        "`{}` forbids {}, and {subject} writes `{pattern}`",
                        bound.regime, category.name
                    ),
                    remediation: category.instead.to_string(),
                    // A rewrite is never mechanical, which is the whole of why
                    // this rule is advisory. See the module comment.
                    patch: None,
                });
            }
        }
        // In document order, and one finding per sentence for each category it
        // offends. Two categories in one sentence are two things to fix.
        findings.sort_by_key(|finding| (finding.line, finding.column));
        Outcome::failed(findings)
    }
}

/// The first pattern of a category that a sentence writes, if any.
///
/// One finding per sentence per category. A sentence that writes `will be`
/// twice offends once, because the author reads the sentence once.
fn matched(category: &Category, sentence: &Sentence) -> Option<&'static str> {
    let text = sentence.authored.to_lowercase();
    category
        .patterns
        .iter()
        .copied()
        .find(|pattern| contains_word(&text, pattern))
}

/// Whether `text` holds `pattern` at word boundaries.
///
/// The boundary matters for the same reason it matters in the abbreviation
/// list of the splitter: `at first` inside `at first-order` is a different
/// phrase, and a rule that reported it would be a false positive that no
/// author can act on.
pub(crate) fn contains_word(text: &str, pattern: &str) -> bool {
    word_at(text, pattern).is_some()
}

/// Where `text` first holds `pattern` at word boundaries, as a byte offset.
///
/// One definition of the boundary rather than two. A retired term with a
/// replacement needs the position as well as the fact, and a second search that
/// drew the boundary differently would patch a word this rule did not report.
pub(crate) fn word_at(text: &str, pattern: &str) -> Option<usize> {
    if pattern.is_empty() {
        return None;
    }
    let mut from = 0usize;
    while let Some(offset) = text.get(from..)?.find(pattern) {
        let at = from + offset;
        let end = at + pattern.len();
        let before = text[..at].chars().next_back().is_none_or(|c| !is_word(c));
        let after = text[end..].chars().next().is_none_or(|c| !is_word(c));
        if before && after {
            return Some(at);
        }
        from = end;
    }
    None
}

fn is_word(c: char) -> bool {
    c.is_alphanumeric() || c == '-'
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_pattern_matches_at_word_boundaries_only() {
        assert!(contains_word("the rule will be read", "will be"));
        assert!(!contains_word("a goodwill better than", "will be"));
        assert!(!contains_word("at first-order logic", "at first"));
        assert!(contains_word("at first, it reads", "at first"));
    }

    /// The set that edition one missed. Each of these is a sentence this
    /// corpus wrote under a declarative regime and edition one passed.
    #[test]
    fn edition_two_reports_the_shapes_edition_one_missed() {
        let narration = &CATEGORIES[1];
        assert_eq!(narration.name, "change_narration");
        for sentence in [
            "until the ruling there was no machine form of this verb",
            "the emitter writes a routine that did not exist before",
            "the table that the entry used to carry decided it on the wrong axis",
            "that script has since retired into the check layer",
            "an earlier version of the entry named a second field",
            "the field is no longer read",
            "the register was formerly a single file",
        ] {
            assert!(
                narration
                    .patterns
                    .iter()
                    .any(|pattern| contains_word(sentence, pattern)),
                "no pattern matched `{sentence}`"
            );
        }
    }

    /// The false positives of the census of 2026-09-11, verbatim from the
    /// corpus of `d0ef8273` and each one
    /// checked against its source file there by a script that ran once. This
    /// case holds its own copies and reads no document, so a reword of any of
    /// these sentences leaves it green while its claim to quote the corpus
    /// goes false. Nothing gates the six against that drift, and a script that
    /// ran once is not a gate. They match, and the
    /// comment on the tier-two set states why they are allowed to: a narrowing
    /// that excludes one of them rules on the mode rather than on the sentence,
    /// and it meets this case before it meets the corpus.
    ///
    /// The census named six sentences in five modes. **The inline quotation
    /// left this set on 2026-09-11 and it left deliberately.** #783 moved it
    /// out of the check layer altogether: the parse marks a quotation inside a
    /// sentence as another author's, `Sentence::authored` drops it, and the
    /// pattern below never receives it. So the mode is excluded by
    /// construction rather than curated here, which is what spec 3 asks for,
    /// and `an_inline_quotation_never_reaches_a_pattern` holds the property in
    /// its place. The other five modes are unchanged and stay curated.
    /// [HW-OBL-0002](../../../../docs/obligations/0002-declarative-voice-is-called-detectable-at-useful-precision.md)
    /// records the census as a point-in-time measurement and is not restated.
    #[test]
    fn the_measured_false_positives_still_match() {
        let narration = &CATEGORIES[1];
        for sentence in [
            // A definition of a change, rather than a narration of one.
            "A term that the corpus no longer uses, declared in the language regime with a required reason and an optional replacement.",
            // A conditional inside a hypothetical.
            "If the engine can name that thing too, the finding is relational, and the argument above no longer holds.",
            // Another system's behavior, in the present tense.
            "ESLint fails a run that carries a suppression which no longer matches, and that is the property `.ste-lint-baseline.json` lacked.",
            // A heading, which is not a sentence.
            "Terms that used to collide",
            // An adjectival compound that names an input rather than a change.
            "A document-scoped check that declares `needs_prior` receives the previously committed version of the changed document.",
        ] {
            assert!(
                narration
                    .patterns
                    .iter()
                    .any(|pattern| contains_word(sentence, pattern)),
                "no pattern matched `{sentence}`, and the ruling of 2026-09-11 records that one does"
            );
        }
    }

    /// The two findings of the census of 2026-09-27 that the rulings of #606
    /// rest on, verbatim from the corpus of `0ee88a2d`. Each one runs the real
    /// path, from the scan to `Sentence::authored` to `matched`, and each one
    /// names the pattern it expects, so a narrowing that moves the finding to
    /// another pattern meets this case as well as one that drops it.
    ///
    /// **`future_intent` reports one finding, and the finding is false.** It
    /// is `docs/taxonomies/diataxis/doctrine.md:46`, and it is the first finding
    /// this category has reported on this corpus. It is a sixth mode, a general
    /// prediction stated as a property of a thing, and nothing about this
    /// system is planned in it. A reader who sees the count move from 0 to 1
    /// and reads it as a set that woke up meets this case first.
    ///
    /// **A decision record's `## Context` answers to the rule like any other
    /// section.** The second source is `docs/decisions/0008-probe-cost-and-cadence.md:26`,
    /// with its heading. The module comment states the ruling and its price.
    /// This case holds the half that the parse owns: the heading does not
    /// keep the sentence from a pattern. A section scope added to `evaluate`
    /// would not fail it. `the_voice_rule_reads_a_context_section_like_any_other`
    /// in `tests/fixtures.rs` runs the whole check and holds that half.
    #[test]
    fn the_rulings_of_2026_09_27_still_match() {
        let first = |category: &Category, source: &str| {
            let body = headwater_doc::body::scan(source, source, 0);
            headwater_doc::sentences::of(&body)
                .iter()
                .find_map(|sentence| matched(category, sentence))
        };

        let intent = &CATEGORIES[0];
        assert_eq!(intent.name, "future_intent");
        assert_eq!(
            first(
                intent,
                "Spec 2 names orthogonality over two facets that are near-perfectly correlated: the redundant one does no work and will eventually disagree with the other.\n"
            ),
            Some("will eventually"),
            "the one `future_intent` finding of 2026-09-27 stopped matching, and the census records it as a false positive that does"
        );

        let narration = &CATEGORIES[1];
        assert_eq!(narration.name, "change_narration");
        assert_eq!(
            first(
                narration,
                "## Context\n\nThere was no declaration, no authored form, no owner, and no definition of the declared expectation that the grader compares against.\n"
            ),
            Some("there was no"),
            "a `## Context` narration stopped reaching `change_narration`, and the ruling of 2026-09-27 declines section scoping"
        );
    }

    /// The pair that replaces the inline-quotation entry above, and the reason
    /// it could leave the curated set. It runs the real path — scan, sentence,
    /// `authored` — rather than handing a pattern a string, because the whole
    /// point of #783 is that the exclusion lives in the parse.
    ///
    /// The first half is `docs/decisions/0021-terminological-succession-and-validity-under-merge.md:32`
    /// verbatim, which was finding 1 of 37 `voice.forbidden_construction` on
    /// `99e1ecae` and is not a finding now.
    ///
    /// **The second half is what makes the first half worth anything.** A
    /// change that dropped the whole sentence rather than the quoted span
    /// would pass the first assertion. So the same words outside the marks
    /// must still reach a pattern, and that is the assertion below it.
    #[test]
    fn an_inline_quotation_never_reaches_a_pattern() {
        let narration = &CATEGORIES[1];
        let matches = |source: &str| {
            let body = headwater_doc::body::scan(source, source, 0);
            headwater_doc::sentences::of(&body).iter().any(|sentence| {
                narration
                    .patterns
                    .iter()
                    .any(|pattern| contains_word(&sentence.authored.to_lowercase(), pattern))
            })
        };

        assert!(
            !matches(
                "The judgment \"we no longer describe it that way\" existed only as prose and a diff, so no mechanism could inherit it.\n"
            ),
            "an inline quotation reached a voice pattern, which spec 3 puts outside every voice rule"
        );
        assert!(
            matches("The corpus no longer describes it that way, and that is the change.\n"),
            "the same words outside a quotation stopped reaching a pattern, so the exclusion is too wide"
        );
    }

    /// The three candidates measured and rejected, held here so that a later
    /// widening does not readmit one. Each sentence is from this corpus.
    #[test]
    fn the_rejected_candidates_stay_out() {
        let narration = &CATEGORIES[1];
        for sentence in [
            "the british spelling and the retired term are warnings",
            "evidence that an author found a reference worth making at one point in a text",
            "every inline quotation of five characters or more was replaced by one word",
        ] {
            assert!(
                !narration
                    .patterns
                    .iter()
                    .any(|pattern| contains_word(sentence, pattern)),
                "a pattern matched `{sentence}`, which is not change narration"
            );
        }
    }
}