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