1use 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
33pub const CITES: &[RuleId] = &[
35 RuleId::SuppressionNamesItsCase,
36 RuleId::PermanentExceptionStatesItsReason,
37];
38
39const CASE: RuleId = RuleId::SuppressionNamesItsCase;
40const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
41
42const MARKER: &str = "sdd: permanent";
44
45#[derive(Clone, Copy, PartialEq, Eq)]
47enum Opener {
48 Line,
50 After(&'static str),
52}
53
54struct 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
107fn 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
121fn 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
147fn 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 #[allow(clippy::case_sensitive_file_extension_comparisons)]
160 file.ends_with(".rs")
161}
162
163fn is_python(file: &str) -> bool {
164 #[allow(clippy::case_sensitive_file_extension_comparisons)]
166 file.ends_with(".py")
167}
168
169fn 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
176fn 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
196fn 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
233fn 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
274fn 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
294fn 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
336const 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 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
364fn 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
389struct 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
432fn 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 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 #[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
524pub 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}