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";
struct Category {
name: &'static str,
instead: &'static str,
patterns: &'static [&'static str],
}
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",
patterns: &[
"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",
"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",
],
},
];
pub struct Voice {
bound: Vec<Bound>,
scent: Option<String>,
}
struct Bound {
kind: String,
regime: String,
known: Vec<usize>,
unknown: Vec<String>,
}
impl Voice {
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 ®ime.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;
const VERSION: u32 = 5;
const NEEDS_BODY: bool = true;
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;
};
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));
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() {
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),
),
(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(),
patch: None,
});
}
}
findings.sort_by_key(|finding| (finding.line, finding.column));
Outcome::failed(findings)
}
}
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))
}
pub(crate) fn contains_word(text: &str, pattern: &str) -> bool {
word_at(text, pattern).is_some()
}
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"));
}
#[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}`"
);
}
}
#[test]
fn the_measured_false_positives_still_match() {
let narration = &CATEGORIES[1];
for sentence in [
"A term that the corpus no longer uses, declared in the language regime with a required reason and an optional replacement.",
"If the engine can name that thing too, the finding is relational, and the argument above no longer holds.",
"ESLint fails a run that carries a suppression which no longer matches, and that is the property `.ste-lint-baseline.json` lacked.",
"Terms that used to collide",
"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"
);
}
}
#[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"
);
}
#[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"
);
}
#[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"
);
}
}
}