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