Skip to main content

spec_driven_docs/gates/
suppression_names_its_case.rs

1//! Gate: every suppression says why it is there, and says it in the one
2//! form that fits what it does.
3//!
4//! A suppression over a defect somewhere else names the case that justifies
5//! it, and that case resolves to a record. A suppression this project chose
6//! and keeps states its reason instead, because no record could carry a
7//! retirement condition anyone can meet, and a record with an unmeetable
8//! condition is the permanent mask the case rule exists to prevent. A
9//! suppression with neither becomes permanent by default: the next reader
10//! takes it for a design choice and nothing says what would retire it.
11//!
12//! A suppression states that reason in either of two places. Where the tool
13//! that honors the form defines a reason position of its own, the reason
14//! written there counts, so a generated artifact another project owns
15//! satisfies this rule in its own idiom and nothing here reaches into bytes
16//! it does not author. Where the tool defines no such position, the
17//! `sdd: permanent` marker is the portable fallback. Only a position the
18//! tool formally defines counts, so prose that merely sits near a
19//! suppression never satisfies the rule.
20//!
21//! A form counts only in a file the tool that honors it reads. `#[allow(`
22//! is live Rust and a quotation in markdown; `noqa` is live in a Python or
23//! shell comment and a quotation here. That scoping is what lets this file,
24//! the specs and the method chapters name a form without being judged by
25//! it. A form inside a string, inside a multi-line string, or inside a
26//! document's fence is a quotation for the same reason. Binary files and
27//! vendored trees are skipped, and the known-issues directory is exempt,
28//! because a record may discuss suppressions.
29//!
30//! Three surfaces stay outside this scan: an extensionless shell script, a
31//! block comment holding a suppression, and the `[lints]` table of a
32//! manifest.
33
34use std::collections::BTreeSet;
35
36use crate::domain::finding::Finding;
37use crate::domain::rule_id::RuleId;
38use crate::gates::markdown_prose::{LineKind, classify};
39use crate::gates::paths::ki_records;
40use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
41
42/// The rules this gate can cite.
43pub const CITES: &[RuleId] = &[
44    RuleId::SuppressionNamesItsCase,
45    RuleId::PermanentExceptionStatesItsReason,
46];
47
48const CASE: RuleId = RuleId::SuppressionNamesItsCase;
49const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
50
51/// The marker a permanent exception carries, ahead of its reason.
52const MARKER: &str = "sdd: permanent";
53
54/// How a form opens the text the tool reads.
55#[derive(Clone, Copy, PartialEq, Eq)]
56enum Opener {
57    /// The token opens the line, as a Rust attribute does.
58    Line,
59    /// The token follows a comment opener on the line.
60    After(&'static str),
61}
62
63/// Where a form's own tool reads the reason the author wrote for it.
64///
65/// Only a position the tool formally defines counts. Prose that merely sits
66/// near a suppression is not a reason channel: accepting it would let an
67/// unrelated comment satisfy the rule.
68#[derive(Clone, Copy, PartialEq, Eq)]
69enum Channel {
70    /// The tool defines no reason position, so the marker is the only form.
71    None,
72    /// The text after the directive's closing bracket, as zizmor reads it.
73    AfterBracket,
74    /// The text after a `--` separator, as `ESLint` reads it.
75    AfterSeparator,
76    /// The string of a `reason` argument, as Rust and pytest read it.
77    ReasonArgument,
78    /// The string of a bare `=` value, as `#[ignore]` reads it.
79    ValueString,
80    /// The first argument, as `unittest.skip` reads its reason.
81    FirstArgument,
82    /// The second argument, as `unittest.skipIf` reads its reason past the
83    /// condition.
84    SecondArgument,
85}
86
87/// One suppression form: the text that opens it and the reason channel its
88/// own tool defines.
89struct Form {
90    token: &'static str,
91    channel: Channel,
92}
93
94const fn form(token: &'static str, channel: Channel) -> Form {
95    Form { token, channel }
96}
97
98/// One family of suppression forms, with the file suffixes it is live in.
99struct Family {
100    suffixes: &'static [&'static str],
101    opener: Opener,
102    forms: &'static [Form],
103}
104
105const FAMILIES: &[Family] = &[
106    Family {
107        suffixes: &[".rs"],
108        opener: Opener::Line,
109        forms: &[
110            form("#[allow(", Channel::ReasonArgument),
111            form("#[expect(", Channel::ReasonArgument),
112            form("#![allow(", Channel::ReasonArgument),
113            form("#![expect(", Channel::ReasonArgument),
114            form("#[ignore", Channel::ValueString),
115        ],
116    },
117    // A longer token is listed ahead of the prefix it extends, because the
118    // first match wins and `@unittest.skipIf` starts with `@unittest.skip`.
119    Family {
120        suffixes: &[".py"],
121        opener: Opener::Line,
122        forms: &[
123            form("@pytest.mark.xfail", Channel::ReasonArgument),
124            form("@pytest.mark.skip", Channel::ReasonArgument),
125            form("@unittest.skipIf", Channel::SecondArgument),
126            form("@unittest.skipUnless", Channel::SecondArgument),
127            form("@unittest.skip", Channel::FirstArgument),
128            form("@unittest.expectedFailure", Channel::None),
129        ],
130    },
131    Family {
132        suffixes: &[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"],
133        opener: Opener::After("#"),
134        forms: &[
135            form("shellcheck disable=", Channel::None),
136            form("noqa", Channel::None),
137            form("ruff: noqa", Channel::None),
138            form("flake8: noqa", Channel::None),
139            form("type: ignore", Channel::None),
140            form("zizmor: ignore[", Channel::AfterBracket),
141        ],
142    },
143    Family {
144        suffixes: &[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
145        opener: Opener::After("//"),
146        forms: &[form("eslint-disable", Channel::AfterSeparator)],
147    },
148    Family {
149        suffixes: &[".md", ".html"],
150        opener: Opener::After("<!--"),
151        forms: &[
152            form("dprint-ignore", Channel::None),
153            form("markdownlint-disable", Channel::None),
154        ],
155    },
156];
157
158/// The comment opener a file's own syntax uses, for the line above a
159/// suppression. The markdown forms have room on their own line, so they
160/// take no window.
161fn comment_opener(file: &str) -> Option<&'static str> {
162    const OPENERS: &[(&[&str], &str)] = &[
163        (&[".rs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], "//"),
164        (&[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"], "#"),
165    ];
166    OPENERS
167        .iter()
168        .find(|(suffixes, _)| suffixes.iter().any(|suffix| file.ends_with(suffix)))
169        .map(|(_, opener)| *opener)
170}
171
172/// Where the line's own comment opens, outside every quoted span.
173///
174/// Quotes are interpreted in the code that precedes the comment and never
175/// inside it, so an apostrophe in comment prose closes nothing and a form
176/// written in a string literal opens nothing.
177fn comment_start(line: &str, opener: &str, quotes: &[char]) -> Option<usize> {
178    let mut open: Option<char> = None;
179    let mut escaped = false;
180    for (index, character) in line.char_indices() {
181        if escaped {
182            escaped = false;
183            continue;
184        }
185        if open.is_none() && line[index..].starts_with(opener) {
186            return Some(index);
187        }
188        match (open, character) {
189            (_, '\\') => escaped = true,
190            (None, character) if quotes.contains(&character) => open = Some(character),
191            (Some(quote), character) if character == quote => open = None,
192            _ => {}
193        }
194    }
195    None
196}
197
198/// The quote delimiters a file's own language carries. A JavaScript
199/// template literal is a string, so a form written in one is a quotation.
200fn quote_marks(file: &str) -> &'static [char] {
201    if comment_opener(file) == Some("//") && !is_rust(file) {
202        &['"', '\'', '`']
203    } else {
204        &['"', '\'']
205    }
206}
207
208fn is_rust(file: &str) -> bool {
209    // sdd: permanent the corpus convention is lowercase, and `.RS` is not Rust
210    #[allow(clippy::case_sensitive_file_extension_comparisons)]
211    file.ends_with(".rs")
212}
213
214fn is_python(file: &str) -> bool {
215    // sdd: permanent the corpus convention is lowercase, and `.PY` is not Python
216    #[allow(clippy::case_sensitive_file_extension_comparisons)]
217    file.ends_with(".py")
218}
219
220/// The line's code, with its comment removed.
221fn code_region<'a>(file: &str, line: &'a str) -> &'a str {
222    comment_opener(file)
223        .and_then(|opener| comment_start(line, opener, quote_marks(file)))
224        .map_or(line, |index| &line[..index])
225}
226
227/// Whether the comment carries `token` right after one of its openers.
228///
229/// The match ignores case, because a linter that honors `noqa` honors
230/// `NOQA` too.
231fn comment_carries(comment: &str, opener: &str, token: &str) -> bool {
232    let lowered = comment.to_ascii_lowercase();
233    let mut start = 0usize;
234    while let Some(offset) = lowered[start..].find(opener) {
235        let index = start + offset;
236        if lowered[index + opener.len()..]
237            .trim_start_matches(' ')
238            .starts_with(token)
239        {
240            return true;
241        }
242        start = index + opener.len();
243    }
244    false
245}
246
247/// Where a suppression on this line starts carrying its annotation, and the
248/// reason channel the form's own tool defines.
249///
250/// A comment-borne form annotates from where the comment opens, so a case
251/// id or a marker written in code earlier on the line is not the
252/// suppression's. A line-borne form carries its annotation on the whole
253/// line: a Rust attribute and a Python decorator both hold their reason
254/// inside themselves.
255fn suppression_at(file: &str, line: &str) -> Option<(usize, &'static Form)> {
256    for family in FAMILIES {
257        if !family.suffixes.iter().any(|suffix| file.ends_with(suffix)) {
258            continue;
259        }
260        match family.opener {
261            Opener::Line => {
262                let code = line.trim_start();
263                if let Some(form) = family
264                    .forms
265                    .iter()
266                    .find(|form| code.starts_with(form.token))
267                {
268                    return Some((0, form));
269                }
270            }
271            Opener::After(opener) => {
272                let Some(index) = comment_start(line, opener, quote_marks(file)) else {
273                    continue;
274                };
275                let comment = &line[index..];
276                if let Some(form) = family
277                    .forms
278                    .iter()
279                    .find(|form| comment_carries(comment, opener, form.token))
280                {
281                    return Some((index, form));
282                }
283            }
284        }
285    }
286    None
287}
288
289/// Every fence delimiter the line opens or closes in live code.
290///
291/// A delimiter inside an ordinary string or a comment is content, so
292/// `delimiter = '\"\"\"'` opens nothing. The fence check runs before the
293/// quote check, so a real triple quote is not read as one ordinary quote.
294fn fence_toggles<'a>(line: &'a str, fences: &[&'a str], comment: Option<&str>) -> Vec<&'a str> {
295    let mut out = Vec::new();
296    let mut open: Option<char> = None;
297    let mut escaped = false;
298    let mut skip_to = 0usize;
299    for (index, character) in line.char_indices() {
300        if index < skip_to {
301            continue;
302        }
303        if escaped {
304            escaped = false;
305            continue;
306        }
307        if open.is_none() {
308            if let Some(fence) = fences
309                .iter()
310                .find(|fence| line[index..].starts_with(**fence))
311            {
312                out.push(*fence);
313                skip_to = index + fence.len();
314                continue;
315            }
316            if comment.is_some_and(|opener| line[index..].starts_with(opener)) {
317                break;
318            }
319        }
320        match (open, character) {
321            (_, '\\') => escaped = true,
322            (None, '"' | '\'' | '`') => open = Some(character),
323            (Some(quote), character) if character == quote => open = None,
324            _ => {}
325        }
326    }
327    out
328}
329
330/// Where `fence` first appears unescaped. An escaped delimiter is part of
331/// the string it sits in, so it closes nothing.
332fn find_unescaped(line: &str, fence: &str) -> Option<usize> {
333    let mut escaped = false;
334    for (index, character) in line.char_indices() {
335        if escaped {
336            escaped = false;
337            continue;
338        }
339        if character == '\\' {
340            escaped = true;
341            continue;
342        }
343        if line[index..].starts_with(fence) {
344            return Some(index);
345        }
346    }
347    None
348}
349
350/// Where each line's live code begins, or `None` where the whole line sits
351/// inside a multi-line string of the file's own language.
352///
353/// A line that opens inside such a string is content up to its closing
354/// delimiter and live code after it, which is where a linter asks for the
355/// suppression a long string earns.
356fn live_from(file: &str, lines: &[&str]) -> Vec<Option<usize>> {
357    let fences: &[&str] = if is_python(file) {
358        &["\"\"\"", "'''"]
359    } else if comment_opener(file) == Some("//") && !is_rust(file) {
360        &["`"]
361    } else {
362        return vec![Some(0); lines.len()];
363    };
364    let comment = comment_opener(file);
365    let mut open: Option<&str> = None;
366    lines
367        .iter()
368        .map(|line| {
369            let Some(fence) = open else {
370                for opened in fence_toggles(line, fences, comment) {
371                    open = match open {
372                        None => Some(opened),
373                        Some(current) if current == opened => None,
374                        Some(current) => Some(current),
375                    };
376                }
377                return Some(0);
378            };
379            let closes = find_unescaped(line, fence);
380            if closes.is_some() {
381                open = None;
382            }
383            closes.map(|index| index + fence.len())
384        })
385        .collect()
386}
387
388fn is_closing(line: &str) -> bool {
389    line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
390}
391
392/// Whether a token starting at `index` opens on a word boundary, so a
393/// longer word that ends in the token is not read as the token.
394const fn on_a_boundary(text: &str, index: usize) -> bool {
395    index == 0
396        || !text.as_bytes()[index - 1].is_ascii_alphanumeric()
397            && text.as_bytes()[index - 1] != b'-'
398            && text.as_bytes()[index - 1] != b'_'
399}
400
401fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
402    line.match_indices("KI-")
403        .filter(|(index, _)| on_a_boundary(line, *index))
404        .filter_map(|(index, _)| {
405            let rest = &line[index + 3..];
406            let slug: String = rest
407                .chars()
408                .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
409                .collect();
410            // The slug closes on a boundary too, so `KI-vendor-quirkXYZ` is
411            // one unknown case rather than a known one with a suffix.
412            let closes = rest[slug.len()..]
413                .chars()
414                .next()
415                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
416            (!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
417        })
418}
419
420/// The reason a permanent marker states on one line.
421///
422/// The reason is read from the marker's own line, so a marker that ends its
423/// line states no reason. Reading past the line end would let the
424/// suppression below a bare marker read as its reason. The marker opens on
425/// a word boundary and closes on whitespace, so `not-sdd: permanent` and
426/// `sdd: permanently` are different words.
427fn permanent_reason(line: &str) -> Option<String> {
428    let after = line
429        .match_indices(MARKER)
430        .filter(|(index, _)| on_a_boundary(line, *index))
431        .map(|(index, _)| &line[index + MARKER.len()..])
432        .find(|after| after.is_empty() || after.starts_with(char::is_whitespace))?;
433    let rest = after
434        .trim_end()
435        .trim_end_matches("-->")
436        .trim_end_matches("*/")
437        .trim();
438    Some(rest.to_string())
439}
440
441/// How many bytes of `text` at `index` open a raw string, and how many
442/// hashes close it. `None` where no raw string opens there.
443///
444/// A hashed raw string ends only at a quote carrying its own hash count, so
445/// a quote inside the body is content.
446fn raw_opener(text: &str, index: usize) -> Option<(usize, usize)> {
447    let after = text[index..].strip_prefix('r')?;
448    let hashes = after.chars().take_while(|c| *c == '#').count();
449    after[hashes..].starts_with('"').then_some((
450        // `r`, the hashes, and the opening quote.
451        1 + hashes + 1,
452        hashes,
453    ))
454}
455
456/// One past the end of the literal that opens at `index`, or `None` where
457/// no literal opens there.
458///
459/// One reader owns literal boundaries, so every scan below agrees on which
460/// text is content. A literal that never closes runs to the end, which
461/// keeps an unbalanced line from reading as no literal at all.
462fn literal_end(text: &str, index: usize) -> Option<usize> {
463    if let Some((opener, hashes)) = raw_opener(text, index) {
464        let body = index + opener;
465        let close = format!("\"{}", "#".repeat(hashes));
466        return Some(
467            text[body..]
468                .find(&close)
469                .map_or(text.len(), |at| body + at + close.len()),
470        );
471    }
472    let quote = text[index..]
473        .chars()
474        .next()
475        .filter(|c| *c == '"' || *c == '\'')?;
476    let body = index + quote.len_utf8();
477    let mut escaped = false;
478    for (at, character) in text[body..].char_indices() {
479        if escaped {
480            escaped = false;
481            continue;
482        }
483        match character {
484            '\\' => escaped = true,
485            c if c == quote => return Some(body + at + c.len_utf8()),
486            _ => {}
487        }
488    }
489    Some(text.len())
490}
491
492/// The character a numeric code point names, written in the given base.
493fn from_code(digits: &str, base: u32) -> Option<char> {
494    u32::from_str_radix(digits, base)
495        .ok()
496        .and_then(char::from_u32)
497}
498
499/// The digits an escape carries, up to `width` of them in the given base.
500fn code_digits(body: &mut std::str::Chars, first: Option<char>, width: usize, base: u32) -> String {
501    let mut digits: String = first.into_iter().collect();
502    while digits.len() < width {
503        match body.clone().next().filter(|c| c.is_digit(base)) {
504            Some(next) => {
505                body.next();
506                digits.push(next);
507            }
508            None => break,
509        }
510    }
511    digits
512}
513
514/// Append what one escape sequence stands for, having already read its
515/// backslash.
516///
517/// The represented character is what the reason says, so `\t`, `\x20`, and
518/// `\040` state the whitespace they are rather than the letters that spell
519/// them. A reason that reads as whitespace states nothing whichever way it
520/// is written.
521///
522/// Python's `\N{name}` names a character this gate cannot resolve without a
523/// Unicode name table, so it contributes nothing rather than its own
524/// letters. A reason spelled only in named escapes therefore reads as
525/// empty, which fails, and a named escape beside real prose leaves that
526/// prose to speak.
527fn push_escaped(out: &mut String, body: &mut std::str::Chars) {
528    let Some(character) = body.next() else { return };
529    match character {
530        'n' => out.push('\n'),
531        'r' => out.push('\r'),
532        't' => out.push('\t'),
533        'f' => out.push('\u{0c}'),
534        'v' => out.push('\u{0b}'),
535        'a' => out.push('\u{07}'),
536        'b' => out.push('\u{08}'),
537        // Python spells an octal escape with up to three digits, and `\0`
538        // is the shortest of them.
539        digit @ '0'..='7' => out.extend(from_code(&code_digits(body, Some(digit), 3, 8), 8)),
540        'x' => out.extend(from_code(&code_digits(body, None, 2, 16), 16)),
541        // A named escape resolves to a character this gate cannot name, so
542        // it is consumed and contributes nothing.
543        'N' if body.clone().next() == Some('{') => {
544            body.take_while(|c| *c != '}').for_each(drop);
545        }
546        // Rust brackets the code point and Python takes a fixed width.
547        'u' | 'U' => {
548            let digits = if body.clone().next() == Some('{') {
549                body.next();
550                body.take_while(|c| *c != '}').collect()
551            } else {
552                code_digits(body, None, if character == 'u' { 4 } else { 8 }, 16)
553            };
554            out.extend(from_code(&digits, 16));
555        }
556        other => out.push(other),
557    }
558}
559
560/// The text of a quoted string starting at `from`, or `None` where no
561/// string opens there. The scan stops at the closing quote, so a reason
562/// carries its own text and nothing after it.
563///
564/// A raw string is one of the spellings Rust's attribute grammar accepts,
565/// so `r"..."` and `r#"..."#` read the same as an ordinary string.
566fn quoted_from(text: &str, from: usize) -> Option<String> {
567    let start = from + text[from..].len() - text[from..].trim_start().len();
568    let rest = &text[start..];
569    if let Some((opener, hashes)) = raw_opener(rest, 0) {
570        let close = format!("\"{}", "#".repeat(hashes));
571        return rest[opener..]
572            .find(&close)
573            .map(|at| rest[opener..opener + at].to_string());
574    }
575    let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?;
576    let mut body = rest[quote.len_utf8()..].chars();
577    let mut out = String::new();
578    while let Some(character) = body.next() {
579        match character {
580            '\\' => push_escaped(&mut out, &mut body),
581            c if c == quote => return Some(out),
582            c => out.push(c),
583        }
584    }
585    None
586}
587
588/// The suppression's own extent, from its token to the delimiter that
589/// closes it.
590///
591/// Everything past that delimiter belongs to whatever else the line
592/// carries. Without the bound, a second attribute on the same line lends
593/// its text to a suppression that states nothing, which is the silent mask
594/// this gate exists to prevent.
595fn extent_from<'a>(code: &'a str, token: &str) -> Option<&'a str> {
596    let start = code.find(token)?;
597    let text = &code[start..];
598    let mut depth = 0i32;
599    let mut opened = false;
600    let mut index = 0usize;
601    while let Some(character) = text[index..].chars().next() {
602        if let Some(end) = literal_end(text, index) {
603            index = end;
604            continue;
605        }
606        match character {
607            '(' | '[' => {
608                depth += 1;
609                opened = true;
610            }
611            ')' | ']' => {
612                depth -= 1;
613                if opened && depth <= 0 {
614                    return Some(&code[start..start + index + character.len_utf8()]);
615                }
616            }
617            _ => {}
618        }
619        index += character.len_utf8();
620    }
621    // A suppression whose delimiters never close owns the rest of the text,
622    // so an unbalanced line still reads as the suppression it is.
623    opened.then_some(&code[start..])
624}
625
626/// Every top-level argument of the call the extent opens, in order.
627fn arguments(extent: &str) -> Vec<&str> {
628    let Some(open) = extent.find('(') else {
629        return Vec::new();
630    };
631    let mut out = Vec::new();
632    let mut depth = 0i32;
633    let mut from = open + 1;
634    let mut at = open + 1;
635    while let Some(character) = extent[at..].chars().next() {
636        if let Some(end) = literal_end(extent, at) {
637            at = end;
638            continue;
639        }
640        match character {
641            '(' | '[' | '{' => depth += 1,
642            ')' | ']' | '}' if depth > 0 => depth -= 1,
643            ')' => {
644                out.push(&extent[from..at]);
645                return out;
646            }
647            ',' if depth == 0 => {
648                out.push(&extent[from..at]);
649                from = at + 1;
650            }
651            _ => {}
652        }
653        at += character.len_utf8();
654    }
655    out
656}
657
658/// The text a `reason` argument carries, in the `reason = "..."` Rust
659/// writes and the `reason="..."` pytest writes.
660///
661/// The name counts outside every quoted span, so the same word inside
662/// another argument's string is that string's text and not a reason.
663fn keyword_value(argument: &str) -> Option<String> {
664    let after = argument.trim_start().strip_prefix("reason")?;
665    let equals = after
666        .find('=')
667        .filter(|at| after[..*at].trim().is_empty())?;
668    // A comparison binds nothing, and a longer name is a different name.
669    if after[equals + 1..].starts_with('=') {
670        return None;
671    }
672    quoted_from(after, equals + 1)
673}
674
675/// The reason the suppression's own `reason` argument carries.
676///
677/// The name is read among the suppression's top-level arguments alone, so a
678/// nested call cannot lend its own reason to a suppression that states
679/// none.
680fn reason_argument(extent: &str) -> Option<String> {
681    arguments(extent)
682        .iter()
683        .find_map(|argument| keyword_value(argument))
684}
685
686/// The reason a call carries in the parameter named `reason`, or in the
687/// slot that parameter occupies when the caller passes it positionally.
688fn positional_reason(extent: &str, slot: usize) -> Option<String> {
689    reason_argument(extent).or_else(|| quoted_from(arguments(extent).get(slot)?, 0))
690}
691
692/// The reason the form's own tool reads, or `None` where the tool defines
693/// no reason position or the author wrote none.
694///
695/// The reason is read from the suppression's own lines alone. A comment
696/// above the suppression is prose the tool never reads, so it satisfies
697/// nothing here. A channel the tool spells in code reads those lines' code
698/// alone, bounded to the suppression's own extent, so neither a trailing
699/// comment nor a second attribute beside it lends a reason.
700fn native_reason(file: &str, channel: Channel, token: &str, region: &[&str]) -> Option<String> {
701    fn trailing(text: &str) -> String {
702        text.trim_end()
703            .trim_end_matches("-->")
704            .trim_end_matches("*/")
705            .trim()
706            .to_string()
707    }
708    match channel {
709        Channel::None => None,
710        Channel::AfterBracket => {
711            let line = region.first()?;
712            let at = line.find(token)?;
713            let close = line[at..].find(']')?;
714            Some(trailing(&line[at + close + 1..]))
715        }
716        // ESLint separates the rule list from the description with a `--`
717        // that stands alone, so a hyphenated rule name is not a separator.
718        Channel::AfterSeparator => {
719            let line = region.first()?;
720            let at = line.find(token)?;
721            let separator = line[at..].find(" -- ")?;
722            Some(trailing(&line[at + separator + 4..]))
723        }
724        Channel::ReasonArgument
725        | Channel::ValueString
726        | Channel::FirstArgument
727        | Channel::SecondArgument => {
728            let code: Vec<&str> = region.iter().map(|line| code_region(file, line)).collect();
729            let joined = code.join("\n");
730            let extent = extent_from(&joined, token)?;
731            match channel {
732                Channel::ReasonArgument => reason_argument(extent),
733                Channel::ValueString => {
734                    let equals = extent.find('=')?;
735                    quoted_from(extent, equals + 1)
736                }
737                // Python binds a keyword argument to its parameter wherever
738                // the caller writes it, so the name wins over the slot.
739                Channel::FirstArgument => positional_reason(extent, 0),
740                _ => positional_reason(extent, 1),
741            }
742        }
743    }
744}
745
746fn looks_binary(bytes: &[u8]) -> bool {
747    bytes.iter().take(4096).any(|&b| b == 0)
748}
749
750/// What a suppression declares about itself.
751///
752/// The disposition carries no reason text, because no caller reads one: the
753/// gate judges that a reason exists and leaves whether it is truthful to
754/// review, exactly as it does for the marker form.
755#[derive(Clone, Copy, PartialEq, Eq)]
756enum Disposition {
757    /// Neither a case nor a reason, so the suppression becomes permanent by
758    /// default.
759    Missing,
760    /// A `KI-<slug>` case, which a record must define.
761    KnownIssue,
762    /// A permanent exception, stated in the tool's own reason channel or in
763    /// the portable marker.
764    Accepted,
765    /// A case and an explicit permanent marker, which are exclusive.
766    Conflict,
767    /// The marker with nothing after it.
768    MarkerWithoutReason,
769}
770
771/// One suppression, with the lines a reader can read its reason from.
772struct Site {
773    file: String,
774    number: usize,
775    line: String,
776    annotation: Vec<String>,
777    /// Where the suppression's own lines start in `annotation`, past the
778    /// comment line above it.
779    region_from: usize,
780    form: &'static Form,
781}
782
783impl Site {
784    fn cites_a_case(&self) -> bool {
785        self.annotation
786            .iter()
787            .any(|line| cited_cases(line).next().is_some())
788    }
789
790    /// The reason the portable marker states, wherever a reader can see it.
791    fn marker_reason(&self) -> Option<String> {
792        self.annotation
793            .iter()
794            .find_map(|line| permanent_reason(line))
795    }
796
797    /// The reason the form's own tool carries, read from the suppression's
798    /// own lines. Whitespace states nothing, so it is no reason.
799    fn native_reason(&self) -> Option<String> {
800        let region: Vec<&str> = self.annotation[self.region_from..]
801            .iter()
802            .map(String::as_str)
803            .collect();
804        native_reason(&self.file, self.form.channel, self.form.token, &region)
805            .filter(|reason| !reason.trim().is_empty())
806    }
807
808    /// The marker is an explicit declaration, so it decides on its own
809    /// wherever the author wrote one. The native channel decides only where
810    /// no marker and no case is present.
811    fn disposition(&self) -> Disposition {
812        match (self.cites_a_case(), self.marker_reason()) {
813            (true, Some(_)) => Disposition::Conflict,
814            (true, None) => Disposition::KnownIssue,
815            (false, Some(reason)) if reason.is_empty() => Disposition::MarkerWithoutReason,
816            (false, Some(_)) => Disposition::Accepted,
817            (false, None) if self.native_reason().is_some() => Disposition::Accepted,
818            (false, None) => Disposition::Missing,
819        }
820    }
821}
822
823fn unbalanced(text: &str) -> i32 {
824    let mut depth = 0i32;
825    let mut open: Option<u8> = None;
826    let mut escaped = false;
827    for byte in text.bytes() {
828        if escaped {
829            escaped = false;
830            continue;
831        }
832        match (open, byte) {
833            (_, b'\\') => escaped = true,
834            (None, b'"' | b'\'') => open = Some(byte),
835            (Some(quote), byte) if byte == quote => open = None,
836            (None, b'(' | b'[') => depth += 1,
837            (None, b')' | b']') => depth -= 1,
838            _ => {}
839        }
840    }
841    depth
842}
843
844/// Every line a reader can read the suppression's reason from: the comment
845/// line above it when the file's syntax has one, the suppression's own
846/// annotation region, and the lines its delimiters continue onto.
847///
848/// The lines stay separate, because a reason is read from the line its
849/// marker sits on. Rust's own idiom carries the reason above the attribute,
850/// and a multi-line inner attribute has no room on its own line.
851///
852/// The second return names where the suppression's own lines start, so a
853/// channel the tool defines is read from those lines and never from the
854/// comment above them.
855fn annotation(file: &str, lines: &[&str], index: usize, start: usize) -> (Vec<String>, usize) {
856    let mut out = Vec::new();
857    if let Some(opener) = comment_opener(file) {
858        if let Some(above) = index
859            .checked_sub(1)
860            .map(|previous| lines[previous].trim_start())
861            .filter(|previous| previous.starts_with(opener))
862        {
863            out.push(above.to_string());
864        }
865    }
866    let region_from = out.len();
867    out.push(lines[index][start..].to_string());
868    // Delimiters are counted in the code alone, so punctuation in a trailing
869    // comment cannot borrow the line below as this suppression's annotation.
870    // A suppression that never balances owns its opening line only.
871    let mut depth = unbalanced(code_region(file, lines[index]));
872    let mut continuation = Vec::new();
873    let mut next = index + 1;
874    while depth > 0 && next < lines.len() {
875        continuation.push(lines[next].to_string());
876        depth += unbalanced(code_region(file, lines[next]));
877        next += 1;
878    }
879    if depth == 0 {
880        out.extend(continuation);
881    }
882    (out, region_from)
883}
884
885fn sites(ctx: &GateCtx) -> Result<Vec<Site>, GateError> {
886    let mut sites = Vec::new();
887    for file in walk_files(ctx) {
888        if file
889            .components()
890            .any(|part| part.as_str() == "known-issues")
891        {
892            continue;
893        }
894        let bytes =
895            std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
896        if looks_binary(&bytes) {
897            continue;
898        }
899        let Ok(text) = String::from_utf8(bytes) else {
900            continue;
901        };
902        let name = file.as_str().trim_start_matches("./").to_string();
903        let lines: Vec<&str> = text.lines().collect();
904        // A fence in a document holds an example of a form rather than a
905        // live one, and every chapter that teaches a form shows it in a
906        // fence. The shared classifier owns which lines those are, so this
907        // gate keeps no second fence state machine.
908        // sdd: permanent the corpus convention is lowercase, and `.MD` is not a document
909        #[allow(clippy::case_sensitive_file_extension_comparisons)]
910        let kinds = if name.ends_with(".md") {
911            classify(&text)
912        } else {
913            Vec::new()
914        };
915        let live = live_from(&name, &lines);
916        for (index, line) in lines.iter().enumerate() {
917            if matches!(
918                kinds.get(index),
919                Some(LineKind::Fence | LineKind::FrontMatter)
920            ) {
921                continue;
922            }
923            let Some(offset) = live[index] else { continue };
924            let Some((found, form)) = suppression_at(&name, &line[offset..]) else {
925                continue;
926            };
927            let start = offset + found;
928            if !is_closing(line) {
929                let (annotation, region_from) = annotation(&name, &lines, index, start);
930                sites.push(Site {
931                    file: name.clone(),
932                    number: index + 1,
933                    line: (*line).to_string(),
934                    annotation,
935                    region_from,
936                    form,
937                });
938            }
939        }
940    }
941    Ok(sites)
942}
943
944/// Judge every suppression in the repository.
945///
946/// # Errors
947///
948/// [`GateError::Io`] when a candidate file cannot be read.
949pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
950    let sites = sites(ctx)?;
951    let mut violations = Vec::new();
952
953    let mut caseless = Vec::new();
954    for site in &sites {
955        match site.disposition() {
956            Disposition::KnownIssue | Disposition::Accepted => {}
957            Disposition::MarkerWithoutReason => {
958                violations.push(Violation::Finding(Finding::on_line(
959                    PERMANENT,
960                    &site.file,
961                    site.number,
962                    "the permanent marker states no reason",
963                )));
964            }
965            Disposition::Conflict => violations.push(Violation::Finding(Finding::on_line(
966                PERMANENT,
967                &site.file,
968                site.number,
969                "names a case and states a permanent exception",
970            ))),
971            Disposition::Missing => caseless.push(site),
972        }
973    }
974    if !caseless.is_empty() {
975        violations.push(Violation::Finding(Finding::global(CASE, "")));
976        for site in caseless {
977            violations.push(Violation::Note(format!(
978                "./{}:{}:{}",
979                site.file, site.number, site.line
980            )));
981        }
982    }
983
984    let known: BTreeSet<String> = ki_records(ctx, args)?
985        .iter()
986        .filter_map(|record| {
987            record
988                .file_name()
989                .map(|name| name.trim_end_matches(".md").to_string())
990        })
991        .collect();
992    let cited: BTreeSet<String> = sites
993        .iter()
994        .flat_map(|site| site.annotation.iter().flat_map(|line| cited_cases(line)))
995        .collect();
996    for case in cited {
997        if !known.contains(&case) {
998            violations.push(Violation::Finding(Finding::global(
999                CASE,
1000                format!("{case} resolves to no record"),
1001            )));
1002        }
1003    }
1004    Ok(violations)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    fn fixture() -> tempfile::TempDir {
1012        let dir = tempfile::tempdir().unwrap();
1013        let records = dir.path().join("_docs/reference/known-issues");
1014        std::fs::create_dir_all(&records).unwrap();
1015        std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
1016        dir
1017    }
1018
1019    fn run_on(name: &str, text: &str) -> Vec<String> {
1020        let dir = fixture();
1021        std::fs::write(dir.path().join(name), text).unwrap();
1022        let ctx = GateCtx::new(dir.path().to_str().unwrap());
1023        run(&ctx, &[])
1024            .unwrap()
1025            .iter()
1026            .map(ToString::to_string)
1027            .collect()
1028    }
1029
1030    #[test]
1031    fn a_repository_without_suppressions_passes() {
1032        let dir = fixture();
1033        let ctx = GateCtx::new(dir.path().to_str().unwrap());
1034        assert!(run(&ctx, &[]).unwrap().is_empty());
1035    }
1036
1037    #[test]
1038    fn every_form_passes_when_it_names_a_record() {
1039        for (name, text) in [
1040            (
1041                "local.md",
1042                "<!-- markdownlint-disable MD013 KI-vendor-quirk -->\n",
1043            ),
1044            ("local.md", "<!-- dprint-ignore KI-vendor-quirk -->\n"),
1045            ("local.rs", "#[allow(dead_code)] // KI-vendor-quirk\n"),
1046            ("local.rs", "#[expect(dead_code)] // KI-vendor-quirk\n"),
1047            ("local.rs", "#[ignore = \"KI-vendor-quirk\"]\n"),
1048            (
1049                "local.sh",
1050                "# shellcheck disable=SC2329  # KI-vendor-quirk\n",
1051            ),
1052            ("local.py", "x = 1  # noqa: E501  KI-vendor-quirk\n"),
1053            ("local.py", "x = 1  # type: ignore  KI-vendor-quirk\n"),
1054            (
1055                "local.yml",
1056                "on: push  # zizmor: ignore[dangerous-triggers] KI-vendor-quirk\n",
1057            ),
1058            (
1059                "local.ts",
1060                "// eslint-disable-next-line no-eval KI-vendor-quirk\n",
1061            ),
1062        ] {
1063            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1064        }
1065    }
1066
1067    #[test]
1068    fn every_form_fails_when_it_says_nothing() {
1069        for (name, text) in [
1070            ("local.md", "<!-- markdownlint-disable MD013 -->\n"),
1071            ("local.rs", "#[allow(dead_code)]\n"),
1072            ("local.rs", "#![allow(clippy::unwrap_used)]\n"),
1073            ("local.sh", "# shellcheck disable=SC2329\n"),
1074            ("local.py", "x = 1  # noqa: E501\n"),
1075            ("local.ts", "// eslint-disable-next-line no-eval\n"),
1076        ] {
1077            let out = run_on(name, text);
1078            assert_eq!(out.len(), 2, "{name}: {text}");
1079            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1080            assert!(out[1].contains(&format!("{name}:1")));
1081        }
1082    }
1083
1084    #[test]
1085    fn a_permanent_marker_with_a_reason_passes() {
1086        for (name, text) in [
1087            (
1088                "local.rs",
1089                "// sdd: permanent the braces are a template placeholder\n#[allow(clippy::x)]\n",
1090            ),
1091            (
1092                "local.rs",
1093                "#[allow(clippy::x)] // sdd: permanent the lint is wrong here\n",
1094            ),
1095            (
1096                "local.sh",
1097                "# shellcheck disable=SC2329  # sdd: permanent reached through a trap\n",
1098            ),
1099            (
1100                "local.md",
1101                "<!-- markdownlint-disable MD013 sdd: permanent the table is data -->\n",
1102            ),
1103        ] {
1104            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1105        }
1106    }
1107
1108    #[test]
1109    fn a_tools_own_reason_states_a_permanent_exception() {
1110        for (name, text) in [
1111            (
1112                "local.yml",
1113                "on: push  # zizmor: ignore[dangerous-triggers] the definition is the trusted one\n",
1114            ),
1115            (
1116                "local.rs",
1117                "#[allow(dead_code, reason = \"the field is the wire format\")]\n",
1118            ),
1119            (
1120                "local.rs",
1121                "#[expect(dead_code, reason = \"the field is the wire format\")]\n",
1122            ),
1123            (
1124                "local.rs",
1125                "#[ignore = \"the fixture needs a live network\"]\n",
1126            ),
1127            (
1128                "local.py",
1129                "@pytest.mark.xfail(reason=\"the parser rejects a valid literal\", strict=True)\ndef test_x():\n    pass\n",
1130            ),
1131            (
1132                "local.ts",
1133                "// eslint-disable-next-line no-eval -- the input is a literal in this file\n",
1134            ),
1135        ] {
1136            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1137        }
1138    }
1139
1140    /// The shape a generated workflow carries, which this project does not
1141    /// author and must not edit.
1142    #[test]
1143    fn a_generated_workflow_states_its_reason_in_its_own_idiom() {
1144        let text = concat!(
1145            "on:\n",
1146            "  # zizmor: ignore[dangerous-triggers] the trigger is what makes this gate\n",
1147            "  # unforgeable, and the header above states why it is safe here.\n",
1148            "  pull_request_target:\n",
1149        );
1150        assert!(run_on("local.yml", text).is_empty());
1151    }
1152
1153    #[test]
1154    fn a_tools_own_reason_left_empty_is_no_reason() {
1155        for (name, text) in [
1156            (
1157                "local.yml",
1158                "on: push  # zizmor: ignore[dangerous-triggers]\n",
1159            ),
1160            ("local.rs", "#[allow(dead_code, reason = \"\")]\n"),
1161            ("local.rs", "#[ignore]\n"),
1162            ("local.ts", "// eslint-disable-next-line no-eval --\n"),
1163        ] {
1164            let out = run_on(name, text);
1165            assert_eq!(out.len(), 2, "{name}: {text}");
1166            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1167        }
1168    }
1169
1170    #[test]
1171    fn a_tool_that_defines_no_reason_position_still_takes_the_marker() {
1172        for (name, text) in [
1173            ("local.py", "x = 1  # noqa: E501 the line is one URL\n"),
1174            (
1175                "local.sh",
1176                "# shellcheck disable=SC2329 reached through a trap\n",
1177            ),
1178            (
1179                "local.md",
1180                "<!-- markdownlint-disable MD013 the table is data -->\n",
1181            ),
1182        ] {
1183            let out = run_on(name, text);
1184            assert_eq!(out.len(), 2, "{name}: {text}");
1185            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1186        }
1187    }
1188
1189    #[test]
1190    fn prose_above_a_suppression_is_not_the_tools_own_reason() {
1191        for (name, text) in [
1192            (
1193                "local.rs",
1194                "// reason = \"this comment is not the attribute\"\n#[allow(dead_code)]\n",
1195            ),
1196            (
1197                "local.rs",
1198                "#[allow(dead_code)] // reason = \"this comment is not the attribute\"\n",
1199            ),
1200        ] {
1201            let out = run_on(name, text);
1202            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1203        }
1204    }
1205
1206    /// A reason belongs to the suppression that carries it, and to no
1207    /// other construct sharing the line.
1208    #[test]
1209    fn a_reason_beside_a_suppression_is_not_the_suppressions() {
1210        for (name, text) in [
1211            (
1212                "local.rs",
1213                "#[allow(dead_code)] #[doc = \"reason = 'unrelated prose'\"] fn f() {}\n",
1214            ),
1215            (
1216                "local.rs",
1217                "#[allow(dead_code)] #[expect(unused, reason = \"the other one states it\")]\n",
1218            ),
1219        ] {
1220            let out = run_on(name, text);
1221            assert_eq!(
1222                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1223                "{name}: {text}"
1224            );
1225        }
1226    }
1227
1228    #[test]
1229    fn a_whitespace_reason_states_nothing() {
1230        for (name, text) in [
1231            ("local.rs", "#[allow(dead_code, reason = \"   \")]\n"),
1232            ("local.rs", "#[ignore = \"  \"]\n"),
1233            (
1234                "local.py",
1235                "@pytest.mark.skip(reason=\"  \")\ndef test_x():\n    pass\n",
1236            ),
1237            (
1238                "local.py",
1239                "@unittest.skip(\"  \")\ndef test_x():\n    pass\n",
1240            ),
1241        ] {
1242            let out = run_on(name, text);
1243            assert_eq!(
1244                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1245                "{name}: {text}"
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn a_raw_string_reason_is_a_reason() {
1252        for text in [
1253            "#[ignore = r\"requires a live service\"]\n",
1254            "#[ignore = r#\"requires a \"live\" service\"#]\n",
1255            "#[allow(dead_code, reason = r\"the field is the wire format\")]\n",
1256        ] {
1257            assert!(run_on("local.rs", text).is_empty(), "{text}");
1258        }
1259    }
1260
1261    #[test]
1262    fn a_positional_skip_reason_is_a_reason() {
1263        for text in [
1264            "@unittest.skip(\"the service is unavailable\")\ndef test_x():\n    pass\n",
1265            "@unittest.skipIf(sys.platform == \"win32\", \"the path is posix only\")\ndef test_x():\n    pass\n",
1266            "@unittest.skipUnless(os.name == \"posix\", \"the path is posix only\")\ndef test_x():\n    pass\n",
1267        ] {
1268            assert!(run_on("local.py", text).is_empty(), "{text}");
1269        }
1270    }
1271
1272    /// The condition is not the reason, so a skip that states only a
1273    /// condition still says nothing.
1274    #[test]
1275    fn a_skip_condition_is_not_its_reason() {
1276        let out = run_on(
1277            "local.py",
1278            "@unittest.skipIf(sys.platform == \"win32\")\ndef test_x():\n    pass\n",
1279        );
1280        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1281    }
1282
1283    /// Python binds a keyword argument to its parameter wherever the caller
1284    /// writes it, so the name states the reason from any slot.
1285    #[test]
1286    fn a_keyword_skip_reason_is_a_reason() {
1287        for text in [
1288            "@unittest.skip(reason=\"the service is unavailable\")\ndef test_x():\n    pass\n",
1289            "@unittest.skipIf(condition=True, reason=\"the path is posix only\")\ndef test_x():\n    pass\n",
1290            "@unittest.skipUnless(reason=\"the path is posix only\", condition=True)\ndef test_x():\n    pass\n",
1291        ] {
1292            assert!(run_on("local.py", text).is_empty(), "{text}");
1293        }
1294    }
1295
1296    /// An escape states the character it stands for, so a reason spelled
1297    /// only in whitespace escapes states nothing.
1298    #[test]
1299    fn an_escaped_whitespace_reason_states_nothing() {
1300        for text in [
1301            "#[allow(dead_code, reason = \"\\t\")]\n",
1302            "#[allow(dead_code, reason = \"\\n\\r\")]\n",
1303        ] {
1304            let out = run_on("local.rs", text);
1305            assert_eq!(
1306                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1307                "{text}"
1308            );
1309        }
1310    }
1311
1312    /// A hashed raw string ends only at a quote carrying its own hashes, so
1313    /// a delimiter inside the body is the reason's own text.
1314    #[test]
1315    fn a_raw_reason_carrying_a_delimiter_is_still_one_literal() {
1316        let text = "#[allow(dead_code, reason = r#\"the token \")]\" is data\"#)]\n";
1317        assert!(run_on("local.rs", text).is_empty());
1318    }
1319
1320    /// A nested call states its own reason, never its caller's.
1321    #[test]
1322    fn a_nested_call_does_not_lend_its_reason() {
1323        for (name, text) in [
1324            (
1325                "local.py",
1326                "@unittest.skipIf(condition=check(reason=\"borrowed\"), reason=\"\")\ndef test_x():\n    pass\n",
1327            ),
1328            (
1329                "local.py",
1330                "@pytest.mark.skip(reason=compute(reason=\"borrowed\"))\ndef test_x():\n    pass\n",
1331            ),
1332        ] {
1333            let out = run_on(name, text);
1334            assert_eq!(
1335                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1336                "{name}: {text}"
1337            );
1338        }
1339    }
1340
1341    /// An escape states the character it names, whatever its spelling.
1342    #[test]
1343    fn a_numeric_whitespace_escape_states_nothing() {
1344        for text in [
1345            "#[allow(dead_code, reason = \"\\x20\")]\n",
1346            "#[allow(dead_code, reason = \"\\u{20}\")]\n",
1347            "#[allow(dead_code, reason = \"\\u{20}\\t\\x20\")]\n",
1348        ] {
1349            let out = run_on("local.rs", text);
1350            assert_eq!(
1351                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1352                "{text}"
1353            );
1354        }
1355    }
1356
1357    /// Every spelling both languages define reads as the character it
1358    /// names, so no whitespace escape closes the rule.
1359    #[test]
1360    fn every_whitespace_escape_states_nothing() {
1361        for (name, text) in [
1362            (
1363                "local.py",
1364                "@unittest.skip(\"\\v\")\ndef test_x():\n    pass\n",
1365            ),
1366            (
1367                "local.py",
1368                "@unittest.skip(\"\\f\")\ndef test_x():\n    pass\n",
1369            ),
1370            (
1371                "local.py",
1372                "@unittest.skip(\"\\040\")\ndef test_x():\n    pass\n",
1373            ),
1374            (
1375                "local.py",
1376                "@unittest.skip(\"\\u0020\")\ndef test_x():\n    pass\n",
1377            ),
1378            (
1379                "local.py",
1380                "@unittest.skip(\"\\N{SPACE}\")\ndef test_x():\n    pass\n",
1381            ),
1382        ] {
1383            let out = run_on(name, text);
1384            assert_eq!(
1385                out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1386                "{name}: {text}"
1387            );
1388        }
1389    }
1390
1391    /// The decoder states the character, so a reason that is not whitespace
1392    /// still reads as one.
1393    #[test]
1394    fn a_numeric_escape_inside_a_reason_keeps_it() {
1395        let text = "#[allow(dead_code, reason = \"the\\x20field is the wire format\")]\n";
1396        assert!(run_on("local.rs", text).is_empty());
1397        let named =
1398            "@unittest.skip(\"\\N{BULLET} the service is unavailable\")\ndef test_x():\n    pass\n";
1399        assert!(run_on("local.py", named).is_empty());
1400    }
1401
1402    /// A name that merely begins with `reason` is a different name.
1403    #[test]
1404    fn a_longer_name_is_not_the_reason_parameter() {
1405        let out = run_on(
1406            "local.py",
1407            "@unittest.skipIf(reason_code == \"x\", 12)\ndef test_x():\n    pass\n",
1408        );
1409        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1410    }
1411
1412    #[test]
1413    fn a_case_inside_a_tools_own_reason_stays_a_known_issue() {
1414        assert!(
1415            run_on(
1416                "local.rs",
1417                "#[allow(dead_code, reason = \"KI-vendor-quirk\")]\n"
1418            )
1419            .is_empty()
1420        );
1421        let out = run_on("local.rs", "#[allow(dead_code, reason = \"KI-absent\")]\n");
1422        assert_eq!(
1423            out[0],
1424            "FAIL spec-to-code:a-suppression-names-its-case: KI-absent resolves to no record"
1425        );
1426    }
1427
1428    #[test]
1429    fn a_marker_on_the_line_above_a_multi_line_attribute_passes() {
1430        let text = "// sdd: permanent a test module panics as its failure signal\n#![allow(\n    clippy::unwrap_used\n)]\n";
1431        assert!(run_on("local.rs", text).is_empty());
1432    }
1433
1434    #[test]
1435    fn a_non_comment_line_above_supplies_nothing() {
1436        let text = "let reason = \"KI-vendor-quirk\";\n#[allow(dead_code)]\n";
1437        let out = run_on("local.rs", text);
1438        assert_eq!(out.len(), 2);
1439        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1440    }
1441
1442    #[test]
1443    fn a_marker_without_a_reason_is_rejected() {
1444        for text in [
1445            "#[allow(dead_code)] // sdd: permanent\n",
1446            "// sdd: permanent\n#[allow(dead_code)]\n",
1447        ] {
1448            let out = run_on("local.rs", text);
1449            assert_eq!(out.len(), 1, "{text}");
1450            assert!(
1451                out[0].ends_with(": the permanent marker states no reason"),
1452                "{text}"
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn an_expected_failure_is_a_suppression() {
1459        assert!(
1460            run_on(
1461                "test_x.py",
1462                "@pytest.mark.xfail(reason=\"KI-vendor-quirk\", strict=True)\ndef test_x():\n    pass\n"
1463            )
1464            .is_empty()
1465        );
1466        let out = run_on(
1467            "test_x.py",
1468            "@pytest.mark.xfail(strict=True)\ndef test_x():\n    pass\n",
1469        );
1470        assert_eq!(out.len(), 2);
1471        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1472    }
1473
1474    #[test]
1475    fn a_form_inside_a_string_is_a_quotation() {
1476        for (name, text) in [
1477            ("local.py", "value = \"# noqa: E501\"\n"),
1478            ("local.sh", "printf '%s' '# shellcheck disable=SC2329'\n"),
1479            (
1480                "local.ts",
1481                "const form = \"// eslint-disable-next-line\";\n",
1482            ),
1483        ] {
1484            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1485        }
1486    }
1487
1488    #[test]
1489    fn a_fenced_example_in_a_document_is_a_quotation() {
1490        for fence in ["```markdown", "~~~markdown", "````markdown"] {
1491            let close = fence.trim_end_matches("markdown");
1492            let text = format!(
1493                "# Chapter\n\n{fence}\n<!-- markdownlint-disable MD013 -->\n{close}\n\nProse.\n"
1494            );
1495            assert!(run_on("chapter.md", &text).is_empty(), "{fence}");
1496        }
1497    }
1498
1499    #[test]
1500    fn an_apostrophe_before_a_live_directive_does_not_hide_it() {
1501        let out = run_on("local.py", "value = \"it's long\"  # noqa: E501\n");
1502        assert_eq!(out.len(), 2);
1503        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1504    }
1505
1506    #[test]
1507    fn a_marker_inside_a_longer_word_is_not_the_marker() {
1508        let out = run_on(
1509            "local.py",
1510            "x = 1  # noqa: E501 not-sdd: permanent reason\n",
1511        );
1512        assert_eq!(out.len(), 2);
1513        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1514    }
1515
1516    #[test]
1517    fn a_case_written_in_code_before_the_comment_is_not_the_suppressions() {
1518        let out = run_on("local.py", "path = \"KI-vendor-quirk.md\"  # noqa: E501\n");
1519        assert_eq!(out.len(), 2);
1520        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1521    }
1522
1523    #[test]
1524    fn a_multiline_expected_failure_carries_its_case() {
1525        let text = "@pytest.mark.xfail(\n    reason=\"KI-vendor-quirk\",\n    strict=True,\n)\ndef test_x():\n    pass\n";
1526        assert!(run_on("test_x.py", text).is_empty());
1527    }
1528
1529    #[test]
1530    fn a_long_attribute_carries_its_case_past_any_line_count() {
1531        let lints = "    clippy::a_lint,\n".repeat(20);
1532        let text = format!("#[allow(\n{lints}    // KI-vendor-quirk\n)]\nfn f() {{}}\n");
1533        assert!(run_on("local.rs", &text).is_empty());
1534    }
1535
1536    #[test]
1537    fn an_apostrophe_in_comment_prose_does_not_hide_a_later_directive() {
1538        let out = run_on("local.py", "value = 1  # don't reflow  # noqa: E501\n");
1539        assert_eq!(out.len(), 2);
1540        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1541    }
1542
1543    #[test]
1544    fn comment_punctuation_does_not_extend_the_annotation() {
1545        let text = "#[allow(dead_code)] // (\nfn kept() {} // KI-vendor-quirk\n";
1546        let out = run_on("local.rs", text);
1547        assert_eq!(out.len(), 2);
1548        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1549    }
1550
1551    #[test]
1552    fn a_line_carrying_multibyte_text_is_scanned_without_panicking() {
1553        assert!(run_on("local.py", "value = \"a 🤖 walks in\"  # a note\n").is_empty());
1554        let out = run_on("local.py", "value = \"a 🤖 walks in\"  # noqa: E501\n");
1555        assert_eq!(out.len(), 2);
1556    }
1557
1558    #[test]
1559    fn a_form_inside_a_multiline_string_is_a_quotation() {
1560        for (name, text) in [
1561            ("local.py", "DOC = \"\"\"\n# noqa: E501\n\"\"\"\n"),
1562            (
1563                "local.ts",
1564                "const doc = `\n// eslint-disable-next-line\n`;\n",
1565            ),
1566            ("local.ts", "const doc = `// eslint-disable-next-line`;\n"),
1567        ] {
1568            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1569        }
1570    }
1571
1572    #[test]
1573    fn a_linters_other_spellings_are_the_same_form() {
1574        for text in [
1575            "value = 1  # NOQA: E501\n",
1576            "# ruff: noqa\n",
1577            "# flake8: noqa\n",
1578        ] {
1579            let out = run_on("local.py", text);
1580            assert_eq!(out.len(), 2, "{text}");
1581            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1582        }
1583    }
1584
1585    #[test]
1586    fn the_line_a_multiline_string_closes_on_is_still_content() {
1587        for (name, text) in [
1588            ("local.py", "DOC = \"\"\"\n# noqa: E501 \"\"\"\n"),
1589            ("local.ts", "const doc = `\n// eslint-disable-next-line`;\n"),
1590        ] {
1591            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1592        }
1593    }
1594
1595    #[test]
1596    fn an_escaped_delimiter_closes_no_multiline_string() {
1597        let text = "const t = `\nconst label = \\`value\\`;\n// eslint-disable-next-line\n`;\n";
1598        assert!(run_on("local.ts", text).is_empty());
1599    }
1600
1601    #[test]
1602    fn a_suppression_after_a_closing_delimiter_is_live() {
1603        let out = run_on(
1604            "local.py",
1605            "DOC = \"\"\"\nlong text\n\"\"\"  # noqa: E501\n",
1606        );
1607        assert_eq!(out.len(), 2);
1608        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1609        assert!(out[1].contains("local.py:3"));
1610        let text = "DOC = \"\"\"\nlong text\n\"\"\"  # noqa: E501 KI-vendor-quirk\n";
1611        assert!(run_on("local.py", text).is_empty());
1612    }
1613
1614    #[test]
1615    fn a_quoted_fence_delimiter_opens_no_multiline_string() {
1616        for opener in [
1617            "delimiter = '\"\"\"'\n",
1618            "# a docstring opens with \"\"\"\n",
1619        ] {
1620            let out = run_on("local.py", &format!("{opener}value = 1  # noqa: E501\n"));
1621            assert_eq!(out.len(), 2, "{opener}");
1622            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1623        }
1624    }
1625
1626    #[test]
1627    fn a_token_that_runs_on_is_not_the_token() {
1628        let out = run_on(
1629            "local.py",
1630            "x = 1  # noqa: E501 sdd: permanently justified\n",
1631        );
1632        assert_eq!(out.len(), 2);
1633        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1634
1635        let out = run_on("local.rs", "#[allow(dead_code)] // KI-vendor-quirkXYZ\n");
1636        assert_eq!(out.len(), 2);
1637        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1638    }
1639
1640    #[test]
1641    fn a_case_and_a_marker_together_are_rejected() {
1642        let out = run_on(
1643            "local.rs",
1644            "#[allow(dead_code)] // KI-vendor-quirk sdd: permanent both\n",
1645        );
1646        assert_eq!(out.len(), 1);
1647        assert!(out[0].ends_with(": names a case and states a permanent exception"));
1648    }
1649
1650    #[test]
1651    fn a_case_resolving_to_no_record_is_rejected() {
1652        let out = run_on(
1653            "local.md",
1654            "<!-- markdownlint-disable KI-absent-record -->\n",
1655        );
1656        assert_eq!(
1657            out,
1658            vec![
1659                "FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
1660                    .to_string()
1661            ]
1662        );
1663    }
1664
1665    #[test]
1666    fn a_form_named_outside_its_file_kind_is_a_quotation() {
1667        for (name, text) in [
1668            (
1669                "prose.md",
1670                "The `#[allow(dead_code)]` attribute suppresses a lint.\n",
1671            ),
1672            (
1673                "prose.md",
1674                "A Python file carries `# noqa: E501` at the line.\n",
1675            ),
1676            (
1677                "local.rs",
1678                "let form = \"<!-- markdownlint-disable -->\";\n",
1679            ),
1680            ("local.rs", "let form = \"# shellcheck disable=SC2329\";\n"),
1681            ("local.rs", "let form = \"// eslint-disable-next-line\";\n"),
1682        ] {
1683            assert!(run_on(name, text).is_empty(), "{name}: {text}");
1684        }
1685    }
1686
1687    #[test]
1688    fn closing_markers_are_not_suppressions() {
1689        let text = "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n";
1690        assert!(run_on("local.md", text).is_empty());
1691    }
1692
1693    #[test]
1694    fn a_vendored_tree_is_skipped() {
1695        let dir = fixture();
1696        let vendored = dir.path().join("third-party/upstream");
1697        std::fs::create_dir_all(&vendored).unwrap();
1698        std::fs::write(vendored.join("hook.py"), "x = 1  # noqa: E501\n").unwrap();
1699        let ctx = GateCtx::new(dir.path().to_str().unwrap());
1700        assert!(run(&ctx, &[]).unwrap().is_empty());
1701    }
1702}