1use 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
35pub const CITES: &[RuleId] = &[
37 RuleId::SuppressionNamesItsCase,
38 RuleId::PermanentExceptionStatesItsReason,
39];
40
41const CASE: RuleId = RuleId::SuppressionNamesItsCase;
42const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
43
44const MARKER: &str = "sdd: permanent";
46
47#[derive(Clone, Copy, PartialEq, Eq)]
49enum Opener {
50 Line,
52 After(&'static str),
54}
55
56struct 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
109fn 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
123fn 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
149fn 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 #[allow(clippy::case_sensitive_file_extension_comparisons)]
162 file.ends_with(".rs")
163}
164
165fn is_python(file: &str) -> bool {
166 #[allow(clippy::case_sensitive_file_extension_comparisons)]
168 file.ends_with(".py")
169}
170
171fn 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
178fn 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
198fn 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
235fn 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
276fn 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
296fn 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
338const 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 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
366fn 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
391struct 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
434fn 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 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 #[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
526pub 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}