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 form counts only in a file the tool that honors it reads. `#[allow(`
13//! is live Rust and a quotation in markdown; `noqa` is live in a Python or
14//! shell comment and a quotation here. That scoping is what lets this file,
15//! the specs and the method chapters name a form without being judged by
16//! it. A form inside a string, inside a multi-line string, or inside a
17//! document's fence is a quotation for the same reason. Binary files and
18//! vendored trees are skipped, and the known-issues directory is exempt,
19//! because a record may discuss suppressions.
20//!
21//! Three surfaces stay outside this scan: an extensionless shell script, a
22//! block comment holding a suppression, and the `[lints]` table of a
23//! manifest. The `simple-english-disable` marker stays outside too,
24//! because `simple-english:an-exception-names-its-reason` already requires
25//! a reason on it, and a second rule would name one defect twice.
26
27use std::collections::BTreeSet;
28
29use crate::domain::finding::Finding;
30use crate::domain::rule_id::RuleId;
31use crate::gates::markdown_prose::{LineKind, classify};
32use crate::gates::paths::ki_records;
33use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
34
35/// The rules this gate can cite.
36pub const CITES: &[RuleId] = &[
37    RuleId::SuppressionNamesItsCase,
38    RuleId::PermanentExceptionStatesItsReason,
39];
40
41const CASE: RuleId = RuleId::SuppressionNamesItsCase;
42const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
43
44/// The marker a permanent exception carries, ahead of its reason.
45const MARKER: &str = "sdd: permanent";
46
47/// How a form opens the text the tool reads.
48#[derive(Clone, Copy, PartialEq, Eq)]
49enum Opener {
50    /// The token opens the line, as a Rust attribute does.
51    Line,
52    /// The token follows a comment opener on the line.
53    After(&'static str),
54}
55
56/// One family of suppression forms, with the file suffixes it is live in.
57struct Family {
58    suffixes: &'static [&'static str],
59    opener: Opener,
60    tokens: &'static [&'static str],
61}
62
63const FAMILIES: &[Family] = &[
64    Family {
65        suffixes: &[".rs"],
66        opener: Opener::Line,
67        tokens: &[
68            "#[allow(",
69            "#[expect(",
70            "#![allow(",
71            "#![expect(",
72            "#[ignore",
73        ],
74    },
75    Family {
76        suffixes: &[".py"],
77        opener: Opener::Line,
78        tokens: &[
79            "@pytest.mark.xfail",
80            "@pytest.mark.skip",
81            "@unittest.skip",
82            "@unittest.expectedFailure",
83        ],
84    },
85    Family {
86        suffixes: &[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"],
87        opener: Opener::After("#"),
88        tokens: &[
89            "shellcheck disable=",
90            "noqa",
91            "ruff: noqa",
92            "flake8: noqa",
93            "type: ignore",
94            "zizmor: ignore[",
95        ],
96    },
97    Family {
98        suffixes: &[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
99        opener: Opener::After("//"),
100        tokens: &["eslint-disable"],
101    },
102    Family {
103        suffixes: &[".md", ".html"],
104        opener: Opener::After("<!--"),
105        tokens: &["dprint-ignore", "markdownlint-disable"],
106    },
107];
108
109/// The comment opener a file's own syntax uses, for the line above a
110/// suppression. The markdown forms have room on their own line, so they
111/// take no window.
112fn comment_opener(file: &str) -> Option<&'static str> {
113    const OPENERS: &[(&[&str], &str)] = &[
114        (&[".rs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], "//"),
115        (&[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"], "#"),
116    ];
117    OPENERS
118        .iter()
119        .find(|(suffixes, _)| suffixes.iter().any(|suffix| file.ends_with(suffix)))
120        .map(|(_, opener)| *opener)
121}
122
123/// Where the line's own comment opens, outside every quoted span.
124///
125/// Quotes are interpreted in the code that precedes the comment and never
126/// inside it, so an apostrophe in comment prose closes nothing and a form
127/// written in a string literal opens nothing.
128fn comment_start(line: &str, opener: &str, quotes: &[char]) -> Option<usize> {
129    let mut open: Option<char> = None;
130    let mut escaped = false;
131    for (index, character) in line.char_indices() {
132        if escaped {
133            escaped = false;
134            continue;
135        }
136        if open.is_none() && line[index..].starts_with(opener) {
137            return Some(index);
138        }
139        match (open, character) {
140            (_, '\\') => escaped = true,
141            (None, character) if quotes.contains(&character) => open = Some(character),
142            (Some(quote), character) if character == quote => open = None,
143            _ => {}
144        }
145    }
146    None
147}
148
149/// The quote delimiters a file's own language carries. A JavaScript
150/// template literal is a string, so a form written in one is a quotation.
151fn quote_marks(file: &str) -> &'static [char] {
152    if comment_opener(file) == Some("//") && !is_rust(file) {
153        &['"', '\'', '`']
154    } else {
155        &['"', '\'']
156    }
157}
158
159fn is_rust(file: &str) -> bool {
160    // sdd: permanent the corpus convention is lowercase, and `.RS` is not Rust
161    #[allow(clippy::case_sensitive_file_extension_comparisons)]
162    file.ends_with(".rs")
163}
164
165fn is_python(file: &str) -> bool {
166    // sdd: permanent the corpus convention is lowercase, and `.PY` is not Python
167    #[allow(clippy::case_sensitive_file_extension_comparisons)]
168    file.ends_with(".py")
169}
170
171/// The line's code, with its comment removed.
172fn code_region<'a>(file: &str, line: &'a str) -> &'a str {
173    comment_opener(file)
174        .and_then(|opener| comment_start(line, opener, quote_marks(file)))
175        .map_or(line, |index| &line[..index])
176}
177
178/// Whether the comment carries `token` right after one of its openers.
179///
180/// The match ignores case, because a linter that honors `noqa` honors
181/// `NOQA` too.
182fn comment_carries(comment: &str, opener: &str, token: &str) -> bool {
183    let lowered = comment.to_ascii_lowercase();
184    let mut start = 0usize;
185    while let Some(offset) = lowered[start..].find(opener) {
186        let index = start + offset;
187        if lowered[index + opener.len()..]
188            .trim_start_matches(' ')
189            .starts_with(token)
190        {
191            return true;
192        }
193        start = index + opener.len();
194    }
195    false
196}
197
198/// Where a suppression on this line starts carrying its annotation.
199///
200/// A comment-borne form annotates from where the comment opens, so a case
201/// id or a marker written in code earlier on the line is not the
202/// suppression's. A line-borne form carries its annotation on the whole
203/// line: a Rust attribute and a Python decorator both hold their reason
204/// inside themselves.
205fn suppression_at(file: &str, line: &str) -> Option<usize> {
206    for family in FAMILIES {
207        if !family.suffixes.iter().any(|suffix| file.ends_with(suffix)) {
208            continue;
209        }
210        match family.opener {
211            Opener::Line => {
212                let code = line.trim_start();
213                if family.tokens.iter().any(|token| code.starts_with(token)) {
214                    return Some(0);
215                }
216            }
217            Opener::After(opener) => {
218                let Some(index) = comment_start(line, opener, quote_marks(file)) else {
219                    continue;
220                };
221                let comment = &line[index..];
222                if family
223                    .tokens
224                    .iter()
225                    .any(|token| comment_carries(comment, opener, token))
226                {
227                    return Some(index);
228                }
229            }
230        }
231    }
232    None
233}
234
235/// Every fence delimiter the line opens or closes in live code.
236///
237/// A delimiter inside an ordinary string or a comment is content, so
238/// `delimiter = '\"\"\"'` opens nothing. The fence check runs before the
239/// quote check, so a real triple quote is not read as one ordinary quote.
240fn fence_toggles<'a>(line: &'a str, fences: &[&'a str], comment: Option<&str>) -> Vec<&'a str> {
241    let mut out = Vec::new();
242    let mut open: Option<char> = None;
243    let mut escaped = false;
244    let mut skip_to = 0usize;
245    for (index, character) in line.char_indices() {
246        if index < skip_to {
247            continue;
248        }
249        if escaped {
250            escaped = false;
251            continue;
252        }
253        if open.is_none() {
254            if let Some(fence) = fences
255                .iter()
256                .find(|fence| line[index..].starts_with(**fence))
257            {
258                out.push(*fence);
259                skip_to = index + fence.len();
260                continue;
261            }
262            if comment.is_some_and(|opener| line[index..].starts_with(opener)) {
263                break;
264            }
265        }
266        match (open, character) {
267            (_, '\\') => escaped = true,
268            (None, '"' | '\'' | '`') => open = Some(character),
269            (Some(quote), character) if character == quote => open = None,
270            _ => {}
271        }
272    }
273    out
274}
275
276/// Where `fence` first appears unescaped. An escaped delimiter is part of
277/// the string it sits in, so it closes nothing.
278fn find_unescaped(line: &str, fence: &str) -> Option<usize> {
279    let mut escaped = false;
280    for (index, character) in line.char_indices() {
281        if escaped {
282            escaped = false;
283            continue;
284        }
285        if character == '\\' {
286            escaped = true;
287            continue;
288        }
289        if line[index..].starts_with(fence) {
290            return Some(index);
291        }
292    }
293    None
294}
295
296/// Where each line's live code begins, or `None` where the whole line sits
297/// inside a multi-line string of the file's own language.
298///
299/// A line that opens inside such a string is content up to its closing
300/// delimiter and live code after it, which is where a linter asks for the
301/// suppression a long string earns.
302fn live_from(file: &str, lines: &[&str]) -> Vec<Option<usize>> {
303    let fences: &[&str] = if is_python(file) {
304        &["\"\"\"", "'''"]
305    } else if comment_opener(file) == Some("//") && !is_rust(file) {
306        &["`"]
307    } else {
308        return vec![Some(0); lines.len()];
309    };
310    let comment = comment_opener(file);
311    let mut open: Option<&str> = None;
312    lines
313        .iter()
314        .map(|line| {
315            let Some(fence) = open else {
316                for opened in fence_toggles(line, fences, comment) {
317                    open = match open {
318                        None => Some(opened),
319                        Some(current) if current == opened => None,
320                        Some(current) => Some(current),
321                    };
322                }
323                return Some(0);
324            };
325            let closes = find_unescaped(line, fence);
326            if closes.is_some() {
327                open = None;
328            }
329            closes.map(|index| index + fence.len())
330        })
331        .collect()
332}
333
334fn is_closing(line: &str) -> bool {
335    line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
336}
337
338/// Whether a token starting at `index` opens on a word boundary, so a
339/// longer word that ends in the token is not read as the token.
340const fn on_a_boundary(text: &str, index: usize) -> bool {
341    index == 0
342        || !text.as_bytes()[index - 1].is_ascii_alphanumeric()
343            && text.as_bytes()[index - 1] != b'-'
344            && text.as_bytes()[index - 1] != b'_'
345}
346
347fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
348    line.match_indices("KI-")
349        .filter(|(index, _)| on_a_boundary(line, *index))
350        .filter_map(|(index, _)| {
351            let rest = &line[index + 3..];
352            let slug: String = rest
353                .chars()
354                .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
355                .collect();
356            // The slug closes on a boundary too, so `KI-vendor-quirkXYZ` is
357            // one unknown case rather than a known one with a suffix.
358            let closes = rest[slug.len()..]
359                .chars()
360                .next()
361                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
362            (!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
363        })
364}
365
366/// The reason a permanent marker states on one line.
367///
368/// The reason is read from the marker's own line, so a marker that ends its
369/// line states no reason. Reading past the line end would let the
370/// suppression below a bare marker read as its reason. The marker opens on
371/// a word boundary and closes on whitespace, so `not-sdd: permanent` and
372/// `sdd: permanently` are different words.
373fn permanent_reason(line: &str) -> Option<String> {
374    let after = line
375        .match_indices(MARKER)
376        .filter(|(index, _)| on_a_boundary(line, *index))
377        .map(|(index, _)| &line[index + MARKER.len()..])
378        .find(|after| after.is_empty() || after.starts_with(char::is_whitespace))?;
379    let rest = after
380        .trim_end()
381        .trim_end_matches("-->")
382        .trim_end_matches("*/")
383        .trim();
384    Some(rest.to_string())
385}
386
387fn looks_binary(bytes: &[u8]) -> bool {
388    bytes.iter().take(4096).any(|&b| b == 0)
389}
390
391/// One suppression, with the lines a reader can read its reason from.
392struct Site {
393    file: String,
394    number: usize,
395    line: String,
396    annotation: Vec<String>,
397}
398
399impl Site {
400    fn cites_a_case(&self) -> bool {
401        self.annotation
402            .iter()
403            .any(|line| cited_cases(line).next().is_some())
404    }
405
406    fn reason(&self) -> Option<String> {
407        self.annotation
408            .iter()
409            .find_map(|line| permanent_reason(line))
410    }
411}
412
413fn unbalanced(text: &str) -> i32 {
414    let mut depth = 0i32;
415    let mut open: Option<u8> = None;
416    let mut escaped = false;
417    for byte in text.bytes() {
418        if escaped {
419            escaped = false;
420            continue;
421        }
422        match (open, byte) {
423            (_, b'\\') => escaped = true,
424            (None, b'"' | b'\'') => open = Some(byte),
425            (Some(quote), byte) if byte == quote => open = None,
426            (None, b'(' | b'[') => depth += 1,
427            (None, b')' | b']') => depth -= 1,
428            _ => {}
429        }
430    }
431    depth
432}
433
434/// Every line a reader can read the suppression's reason from: the comment
435/// line above it when the file's syntax has one, the suppression's own
436/// annotation region, and the lines its delimiters continue onto.
437///
438/// The lines stay separate, because a reason is read from the line its
439/// marker sits on. Rust's own idiom carries the reason above the attribute,
440/// and a multi-line inner attribute has no room on its own line.
441fn annotation(file: &str, lines: &[&str], index: usize, start: usize) -> Vec<String> {
442    let mut out = Vec::new();
443    if let Some(opener) = comment_opener(file) {
444        if let Some(above) = index
445            .checked_sub(1)
446            .map(|previous| lines[previous].trim_start())
447            .filter(|previous| previous.starts_with(opener))
448        {
449            out.push(above.to_string());
450        }
451    }
452    out.push(lines[index][start..].to_string());
453    // Delimiters are counted in the code alone, so punctuation in a trailing
454    // comment cannot borrow the line below as this suppression's annotation.
455    // A suppression that never balances owns its opening line only.
456    let mut depth = unbalanced(code_region(file, lines[index]));
457    let mut continuation = Vec::new();
458    let mut next = index + 1;
459    while depth > 0 && next < lines.len() {
460        continuation.push(lines[next].to_string());
461        depth += unbalanced(code_region(file, lines[next]));
462        next += 1;
463    }
464    if depth == 0 {
465        out.extend(continuation);
466    }
467    out
468}
469
470fn sites(ctx: &GateCtx) -> Result<Vec<Site>, GateError> {
471    let mut sites = Vec::new();
472    for file in walk_files(ctx) {
473        if file
474            .components()
475            .any(|part| part.as_str() == "known-issues")
476        {
477            continue;
478        }
479        let bytes =
480            std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
481        if looks_binary(&bytes) {
482            continue;
483        }
484        let Ok(text) = String::from_utf8(bytes) else {
485            continue;
486        };
487        let name = file.as_str().trim_start_matches("./").to_string();
488        let lines: Vec<&str> = text.lines().collect();
489        // A fence in a document holds an example of a form rather than a
490        // live one, and every chapter that teaches a form shows it in a
491        // fence. The shared classifier owns which lines those are, so this
492        // gate keeps no second fence state machine.
493        // sdd: permanent the corpus convention is lowercase, and `.MD` is not a document
494        #[allow(clippy::case_sensitive_file_extension_comparisons)]
495        let kinds = if name.ends_with(".md") {
496            classify(&text)
497        } else {
498            Vec::new()
499        };
500        let live = live_from(&name, &lines);
501        for (index, line) in lines.iter().enumerate() {
502            if matches!(
503                kinds.get(index),
504                Some(LineKind::Fence | LineKind::FrontMatter)
505            ) {
506                continue;
507            }
508            let Some(offset) = live[index] else { continue };
509            let Some(found) = suppression_at(&name, &line[offset..]) else {
510                continue;
511            };
512            let start = offset + found;
513            if !is_closing(line) {
514                sites.push(Site {
515                    file: name.clone(),
516                    number: index + 1,
517                    line: (*line).to_string(),
518                    annotation: annotation(&name, &lines, index, start),
519                });
520            }
521        }
522    }
523    Ok(sites)
524}
525
526/// Judge every suppression in the repository.
527///
528/// # Errors
529///
530/// [`GateError::Io`] when a candidate file cannot be read.
531pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
532    let sites = sites(ctx)?;
533    let mut violations = Vec::new();
534
535    let mut caseless = Vec::new();
536    for site in &sites {
537        match (site.cites_a_case(), site.reason()) {
538            (true, None) => {}
539            (false, Some(reason)) if !reason.is_empty() => {}
540            (false, Some(_)) => violations.push(Violation::Finding(Finding::on_line(
541                PERMANENT,
542                &site.file,
543                site.number,
544                "the permanent marker states no reason",
545            ))),
546            (true, Some(_)) => violations.push(Violation::Finding(Finding::on_line(
547                PERMANENT,
548                &site.file,
549                site.number,
550                "names a case and states a permanent exception",
551            ))),
552            (false, None) => caseless.push(site),
553        }
554    }
555    if !caseless.is_empty() {
556        violations.push(Violation::Finding(Finding::global(CASE, "")));
557        for site in caseless {
558            violations.push(Violation::Note(format!(
559                "./{}:{}:{}",
560                site.file, site.number, site.line
561            )));
562        }
563    }
564
565    let known: BTreeSet<String> = ki_records(ctx, args)?
566        .iter()
567        .filter_map(|record| {
568            record
569                .file_name()
570                .map(|name| name.trim_end_matches(".md").to_string())
571        })
572        .collect();
573    let cited: BTreeSet<String> = sites
574        .iter()
575        .flat_map(|site| site.annotation.iter().flat_map(|line| cited_cases(line)))
576        .collect();
577    for case in cited {
578        if !known.contains(&case) {
579            violations.push(Violation::Finding(Finding::global(
580                CASE,
581                format!("{case} resolves to no record"),
582            )));
583        }
584    }
585    Ok(violations)
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    fn fixture() -> tempfile::TempDir {
593        let dir = tempfile::tempdir().unwrap();
594        let records = dir.path().join("_docs/reference/known-issues");
595        std::fs::create_dir_all(&records).unwrap();
596        std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
597        dir
598    }
599
600    fn run_on(name: &str, text: &str) -> Vec<String> {
601        let dir = fixture();
602        std::fs::write(dir.path().join(name), text).unwrap();
603        let ctx = GateCtx::new(dir.path().to_str().unwrap());
604        run(&ctx, &[])
605            .unwrap()
606            .iter()
607            .map(ToString::to_string)
608            .collect()
609    }
610
611    #[test]
612    fn a_repository_without_suppressions_passes() {
613        let dir = fixture();
614        let ctx = GateCtx::new(dir.path().to_str().unwrap());
615        assert!(run(&ctx, &[]).unwrap().is_empty());
616    }
617
618    #[test]
619    fn every_form_passes_when_it_names_a_record() {
620        for (name, text) in [
621            (
622                "local.md",
623                "<!-- markdownlint-disable MD013 KI-vendor-quirk -->\n",
624            ),
625            ("local.md", "<!-- dprint-ignore KI-vendor-quirk -->\n"),
626            ("local.rs", "#[allow(dead_code)] // KI-vendor-quirk\n"),
627            ("local.rs", "#[expect(dead_code)] // KI-vendor-quirk\n"),
628            ("local.rs", "#[ignore = \"KI-vendor-quirk\"]\n"),
629            (
630                "local.sh",
631                "# shellcheck disable=SC2329  # KI-vendor-quirk\n",
632            ),
633            ("local.py", "x = 1  # noqa: E501  KI-vendor-quirk\n"),
634            ("local.py", "x = 1  # type: ignore  KI-vendor-quirk\n"),
635            (
636                "local.yml",
637                "on: push  # zizmor: ignore[dangerous-triggers] KI-vendor-quirk\n",
638            ),
639            (
640                "local.ts",
641                "// eslint-disable-next-line no-eval KI-vendor-quirk\n",
642            ),
643        ] {
644            assert!(run_on(name, text).is_empty(), "{name}: {text}");
645        }
646    }
647
648    #[test]
649    fn every_form_fails_when_it_says_nothing() {
650        for (name, text) in [
651            ("local.md", "<!-- markdownlint-disable MD013 -->\n"),
652            ("local.rs", "#[allow(dead_code)]\n"),
653            ("local.rs", "#![allow(clippy::unwrap_used)]\n"),
654            ("local.sh", "# shellcheck disable=SC2329\n"),
655            ("local.py", "x = 1  # noqa: E501\n"),
656            ("local.ts", "// eslint-disable-next-line no-eval\n"),
657        ] {
658            let out = run_on(name, text);
659            assert_eq!(out.len(), 2, "{name}: {text}");
660            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
661            assert!(out[1].contains(&format!("{name}:1")));
662        }
663    }
664
665    #[test]
666    fn a_permanent_marker_with_a_reason_passes() {
667        for (name, text) in [
668            (
669                "local.rs",
670                "// sdd: permanent the braces are a template placeholder\n#[allow(clippy::x)]\n",
671            ),
672            (
673                "local.rs",
674                "#[allow(clippy::x)] // sdd: permanent the lint is wrong here\n",
675            ),
676            (
677                "local.sh",
678                "# shellcheck disable=SC2329  # sdd: permanent reached through a trap\n",
679            ),
680            (
681                "local.md",
682                "<!-- markdownlint-disable MD013 sdd: permanent the table is data -->\n",
683            ),
684        ] {
685            assert!(run_on(name, text).is_empty(), "{name}: {text}");
686        }
687    }
688
689    #[test]
690    fn a_marker_on_the_line_above_a_multi_line_attribute_passes() {
691        let text = "// sdd: permanent a test module panics as its failure signal\n#![allow(\n    clippy::unwrap_used\n)]\n";
692        assert!(run_on("local.rs", text).is_empty());
693    }
694
695    #[test]
696    fn a_non_comment_line_above_supplies_nothing() {
697        let text = "let reason = \"KI-vendor-quirk\";\n#[allow(dead_code)]\n";
698        let out = run_on("local.rs", text);
699        assert_eq!(out.len(), 2);
700        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
701    }
702
703    #[test]
704    fn a_marker_without_a_reason_is_rejected() {
705        for text in [
706            "#[allow(dead_code)] // sdd: permanent\n",
707            "// sdd: permanent\n#[allow(dead_code)]\n",
708        ] {
709            let out = run_on("local.rs", text);
710            assert_eq!(out.len(), 1, "{text}");
711            assert!(
712                out[0].ends_with(": the permanent marker states no reason"),
713                "{text}"
714            );
715        }
716    }
717
718    #[test]
719    fn an_expected_failure_is_a_suppression() {
720        assert!(
721            run_on(
722                "test_x.py",
723                "@pytest.mark.xfail(reason=\"KI-vendor-quirk\", strict=True)\ndef test_x():\n    pass\n"
724            )
725            .is_empty()
726        );
727        let out = run_on(
728            "test_x.py",
729            "@pytest.mark.xfail(strict=True)\ndef test_x():\n    pass\n",
730        );
731        assert_eq!(out.len(), 2);
732        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
733    }
734
735    #[test]
736    fn a_form_inside_a_string_is_a_quotation() {
737        for (name, text) in [
738            ("local.py", "value = \"# noqa: E501\"\n"),
739            ("local.sh", "printf '%s' '# shellcheck disable=SC2329'\n"),
740            (
741                "local.ts",
742                "const form = \"// eslint-disable-next-line\";\n",
743            ),
744        ] {
745            assert!(run_on(name, text).is_empty(), "{name}: {text}");
746        }
747    }
748
749    #[test]
750    fn a_fenced_example_in_a_document_is_a_quotation() {
751        for fence in ["```markdown", "~~~markdown", "````markdown"] {
752            let close = fence.trim_end_matches("markdown");
753            let text = format!(
754                "# Chapter\n\n{fence}\n<!-- markdownlint-disable MD013 -->\n{close}\n\nProse.\n"
755            );
756            assert!(run_on("chapter.md", &text).is_empty(), "{fence}");
757        }
758    }
759
760    #[test]
761    fn an_apostrophe_before_a_live_directive_does_not_hide_it() {
762        let out = run_on("local.py", "value = \"it's long\"  # noqa: E501\n");
763        assert_eq!(out.len(), 2);
764        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
765    }
766
767    #[test]
768    fn a_marker_inside_a_longer_word_is_not_the_marker() {
769        let out = run_on(
770            "local.py",
771            "x = 1  # noqa: E501 not-sdd: permanent reason\n",
772        );
773        assert_eq!(out.len(), 2);
774        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
775    }
776
777    #[test]
778    fn a_case_written_in_code_before_the_comment_is_not_the_suppressions() {
779        let out = run_on("local.py", "path = \"KI-vendor-quirk.md\"  # noqa: E501\n");
780        assert_eq!(out.len(), 2);
781        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
782    }
783
784    #[test]
785    fn a_multiline_expected_failure_carries_its_case() {
786        let text = "@pytest.mark.xfail(\n    reason=\"KI-vendor-quirk\",\n    strict=True,\n)\ndef test_x():\n    pass\n";
787        assert!(run_on("test_x.py", text).is_empty());
788    }
789
790    #[test]
791    fn a_long_attribute_carries_its_case_past_any_line_count() {
792        let lints = "    clippy::a_lint,\n".repeat(20);
793        let text = format!("#[allow(\n{lints}    // KI-vendor-quirk\n)]\nfn f() {{}}\n");
794        assert!(run_on("local.rs", &text).is_empty());
795    }
796
797    #[test]
798    fn an_apostrophe_in_comment_prose_does_not_hide_a_later_directive() {
799        let out = run_on("local.py", "value = 1  # don't reflow  # noqa: E501\n");
800        assert_eq!(out.len(), 2);
801        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
802    }
803
804    #[test]
805    fn comment_punctuation_does_not_extend_the_annotation() {
806        let text = "#[allow(dead_code)] // (\nfn kept() {} // KI-vendor-quirk\n";
807        let out = run_on("local.rs", text);
808        assert_eq!(out.len(), 2);
809        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
810    }
811
812    #[test]
813    fn a_line_carrying_multibyte_text_is_scanned_without_panicking() {
814        assert!(run_on("local.py", "value = \"a 🤖 walks in\"  # a note\n").is_empty());
815        let out = run_on("local.py", "value = \"a 🤖 walks in\"  # noqa: E501\n");
816        assert_eq!(out.len(), 2);
817    }
818
819    #[test]
820    fn a_form_inside_a_multiline_string_is_a_quotation() {
821        for (name, text) in [
822            ("local.py", "DOC = \"\"\"\n# noqa: E501\n\"\"\"\n"),
823            (
824                "local.ts",
825                "const doc = `\n// eslint-disable-next-line\n`;\n",
826            ),
827            ("local.ts", "const doc = `// eslint-disable-next-line`;\n"),
828        ] {
829            assert!(run_on(name, text).is_empty(), "{name}: {text}");
830        }
831    }
832
833    #[test]
834    fn a_linters_other_spellings_are_the_same_form() {
835        for text in [
836            "value = 1  # NOQA: E501\n",
837            "# ruff: noqa\n",
838            "# flake8: noqa\n",
839        ] {
840            let out = run_on("local.py", text);
841            assert_eq!(out.len(), 2, "{text}");
842            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
843        }
844    }
845
846    #[test]
847    fn the_line_a_multiline_string_closes_on_is_still_content() {
848        for (name, text) in [
849            ("local.py", "DOC = \"\"\"\n# noqa: E501 \"\"\"\n"),
850            ("local.ts", "const doc = `\n// eslint-disable-next-line`;\n"),
851        ] {
852            assert!(run_on(name, text).is_empty(), "{name}: {text}");
853        }
854    }
855
856    #[test]
857    fn an_escaped_delimiter_closes_no_multiline_string() {
858        let text = "const t = `\nconst label = \\`value\\`;\n// eslint-disable-next-line\n`;\n";
859        assert!(run_on("local.ts", text).is_empty());
860    }
861
862    #[test]
863    fn a_suppression_after_a_closing_delimiter_is_live() {
864        let out = run_on(
865            "local.py",
866            "DOC = \"\"\"\nlong text\n\"\"\"  # noqa: E501\n",
867        );
868        assert_eq!(out.len(), 2);
869        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
870        assert!(out[1].contains("local.py:3"));
871        let text = "DOC = \"\"\"\nlong text\n\"\"\"  # noqa: E501 KI-vendor-quirk\n";
872        assert!(run_on("local.py", text).is_empty());
873    }
874
875    #[test]
876    fn a_quoted_fence_delimiter_opens_no_multiline_string() {
877        for opener in [
878            "delimiter = '\"\"\"'\n",
879            "# a docstring opens with \"\"\"\n",
880        ] {
881            let out = run_on("local.py", &format!("{opener}value = 1  # noqa: E501\n"));
882            assert_eq!(out.len(), 2, "{opener}");
883            assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
884        }
885    }
886
887    #[test]
888    fn a_token_that_runs_on_is_not_the_token() {
889        let out = run_on(
890            "local.py",
891            "x = 1  # noqa: E501 sdd: permanently justified\n",
892        );
893        assert_eq!(out.len(), 2);
894        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
895
896        let out = run_on("local.rs", "#[allow(dead_code)] // KI-vendor-quirkXYZ\n");
897        assert_eq!(out.len(), 2);
898        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
899    }
900
901    #[test]
902    fn a_case_and_a_marker_together_are_rejected() {
903        let out = run_on(
904            "local.rs",
905            "#[allow(dead_code)] // KI-vendor-quirk sdd: permanent both\n",
906        );
907        assert_eq!(out.len(), 1);
908        assert!(out[0].ends_with(": names a case and states a permanent exception"));
909    }
910
911    #[test]
912    fn a_case_resolving_to_no_record_is_rejected() {
913        let out = run_on(
914            "local.md",
915            "<!-- markdownlint-disable KI-absent-record -->\n",
916        );
917        assert_eq!(
918            out,
919            vec![
920                "FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
921                    .to_string()
922            ]
923        );
924    }
925
926    #[test]
927    fn a_form_named_outside_its_file_kind_is_a_quotation() {
928        for (name, text) in [
929            (
930                "prose.md",
931                "The `#[allow(dead_code)]` attribute suppresses a lint.\n",
932            ),
933            (
934                "prose.md",
935                "A Python file carries `# noqa: E501` at the line.\n",
936            ),
937            (
938                "local.rs",
939                "let form = \"<!-- markdownlint-disable -->\";\n",
940            ),
941            ("local.rs", "let form = \"# shellcheck disable=SC2329\";\n"),
942            ("local.rs", "let form = \"// eslint-disable-next-line\";\n"),
943        ] {
944            assert!(run_on(name, text).is_empty(), "{name}: {text}");
945        }
946    }
947
948    #[test]
949    fn closing_markers_are_not_suppressions() {
950        let text = "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n";
951        assert!(run_on("local.md", text).is_empty());
952    }
953
954    #[test]
955    fn a_vendored_tree_is_skipped() {
956        let dir = fixture();
957        let vendored = dir.path().join("third-party/upstream");
958        std::fs::create_dir_all(&vendored).unwrap();
959        std::fs::write(vendored.join("hook.py"), "x = 1  # noqa: E501\n").unwrap();
960        let ctx = GateCtx::new(dir.path().to_str().unwrap());
961        assert!(run(&ctx, &[]).unwrap().is_empty());
962    }
963}