1use std::collections::BTreeSet;
42
43use camino::Utf8Path;
44
45use crate::domain::finding::Finding;
46use crate::domain::rule_id::RuleId;
47use crate::gates::markdown_prose::{LineKind, classify};
48use crate::gates::paths::ki_records;
49use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
50
51pub const CITES: &[RuleId] = &[
53 RuleId::SuppressionNamesItsCase,
54 RuleId::PermanentExceptionStatesItsReason,
55];
56
57const CASE: RuleId = RuleId::SuppressionNamesItsCase;
58const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
59
60const MARKER: &str = "sdd: permanent";
62
63#[derive(Clone, Copy, PartialEq, Eq)]
65enum Opener {
66 Line,
68 After(&'static str),
70}
71
72#[derive(Clone, Copy, PartialEq, Eq)]
78enum Channel {
79 None,
81 AfterBracket,
83 AfterSeparator,
85 ReasonArgument,
87 ValueString,
89 FirstArgument,
91 SecondArgument,
94}
95
96struct Form {
99 token: &'static str,
100 channel: Channel,
101}
102
103const fn form(token: &'static str, channel: Channel) -> Form {
104 Form { token, channel }
105}
106
107struct Family {
109 suffixes: &'static [&'static str],
110 opener: Opener,
111 forms: &'static [Form],
112}
113
114const FAMILIES: &[Family] = &[
115 Family {
116 suffixes: &[".rs"],
117 opener: Opener::Line,
118 forms: &[
119 form("#[allow(", Channel::ReasonArgument),
120 form("#[expect(", Channel::ReasonArgument),
121 form("#![allow(", Channel::ReasonArgument),
122 form("#![expect(", Channel::ReasonArgument),
123 form("#[ignore", Channel::ValueString),
124 ],
125 },
126 Family {
129 suffixes: &[".py"],
130 opener: Opener::Line,
131 forms: &[
132 form("@pytest.mark.xfail", Channel::ReasonArgument),
133 form("@pytest.mark.skip", Channel::ReasonArgument),
134 form("@unittest.skipIf", Channel::SecondArgument),
135 form("@unittest.skipUnless", Channel::SecondArgument),
136 form("@unittest.skip", Channel::FirstArgument),
137 form("@unittest.expectedFailure", Channel::None),
138 ],
139 },
140 Family {
141 suffixes: &[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"],
142 opener: Opener::After("#"),
143 forms: &[
144 form("shellcheck disable=", Channel::None),
145 form("noqa", Channel::None),
146 form("ruff: noqa", Channel::None),
147 form("flake8: noqa", Channel::None),
148 form("type: ignore", Channel::None),
149 form("zizmor: ignore[", Channel::AfterBracket),
150 ],
151 },
152 Family {
153 suffixes: &[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
154 opener: Opener::After("//"),
155 forms: &[form("eslint-disable", Channel::AfterSeparator)],
156 },
157 Family {
158 suffixes: &[".md", ".html"],
159 opener: Opener::After("<!--"),
160 forms: &[
161 form("dprint-ignore", Channel::None),
162 form("markdownlint-disable", Channel::None),
163 ],
164 },
165];
166
167fn comment_opener(file: &str) -> Option<&'static str> {
171 const OPENERS: &[(&[&str], &str)] = &[
172 (&[".rs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], "//"),
173 (&[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"], "#"),
174 ];
175 OPENERS
176 .iter()
177 .find(|(suffixes, _)| suffixes.iter().any(|suffix| file.ends_with(suffix)))
178 .map(|(_, opener)| *opener)
179}
180
181fn kind_name(name: &str, first: &str) -> String {
187 let stem = Utf8Path::new(name).file_name().unwrap_or(name);
191 if stem.contains('.') {
192 return name.to_string();
193 }
194 let Some(rest) = first.strip_prefix("#!") else {
195 return name.to_string();
196 };
197 match interpreter(rest) {
198 Some("sh" | "bash" | "dash" | "ksh" | "zsh") => format!("{name}.sh"),
199 Some("python" | "python3") => format!("{name}.py"),
200 _ => name.to_string(),
201 }
202}
203
204fn interpreter(rest: &str) -> Option<&str> {
225 const TAKES_A_WORD: &[&str] = &["-u", "--unset", "-C", "--chdir", "-a", "--argv0"];
229 fn basename(word: &str) -> &str {
230 word.rsplit('/').next().unwrap_or(word)
231 }
232 fn take_argument<'a>(words: &mut impl Iterator<Item = &'a str>) {
236 let Some(word) = words.next() else { return };
237 let Some(quote) = word.chars().next().filter(|c| *c == '"' || *c == '\'') else {
238 return;
239 };
240 if word.len() > 1 && word.ends_with(quote) {
241 return;
242 }
243 words
244 .take_while(|word| !word.ends_with(quote))
245 .for_each(drop);
246 }
247 let mut words = rest.split_whitespace();
248 let first = basename(words.next()?);
249 if first != "env" {
250 return Some(first);
251 }
252 while let Some(word) = words.next() {
253 if TAKES_A_WORD.contains(&word) {
254 take_argument(&mut words);
255 } else if !word.starts_with('-') && !word.contains('=') {
256 return Some(basename(word.trim_matches(['"', '\''])));
257 }
258 }
259 None
260}
261
262fn comment_start(line: &str, opener: &str, quotes: &[char]) -> Option<usize> {
268 let mut open: Option<char> = None;
269 let mut escaped = false;
270 for (index, character) in line.char_indices() {
271 if escaped {
272 escaped = false;
273 continue;
274 }
275 if open.is_none() && line[index..].starts_with(opener) {
276 return Some(index);
277 }
278 match (open, character) {
279 (_, '\\') => escaped = true,
280 (None, character) if quotes.contains(&character) => open = Some(character),
281 (Some(quote), character) if character == quote => open = None,
282 _ => {}
283 }
284 }
285 None
286}
287
288fn quote_marks(file: &str) -> &'static [char] {
291 if comment_opener(file) == Some("//") && !is_rust(file) {
292 &['"', '\'', '`']
293 } else {
294 &['"', '\'']
295 }
296}
297
298fn is_rust(file: &str) -> bool {
299 #[allow(clippy::case_sensitive_file_extension_comparisons)]
301 file.ends_with(".rs")
302}
303
304fn is_python(file: &str) -> bool {
305 #[allow(clippy::case_sensitive_file_extension_comparisons)]
307 file.ends_with(".py")
308}
309
310fn code_region<'a>(file: &str, line: &'a str) -> &'a str {
312 comment_opener(file)
313 .and_then(|opener| comment_start(line, opener, quote_marks(file)))
314 .map_or(line, |index| &line[..index])
315}
316
317fn comment_carries(comment: &str, opener: &str, token: &str) -> bool {
322 let lowered = comment.to_ascii_lowercase();
323 let mut start = 0usize;
324 while let Some(offset) = lowered[start..].find(opener) {
325 let index = start + offset;
326 if lowered[index + opener.len()..]
327 .trim_start_matches(' ')
328 .starts_with(token)
329 {
330 return true;
331 }
332 start = index + opener.len();
333 }
334 false
335}
336
337fn suppression_at(file: &str, line: &str) -> Option<(usize, &'static Form)> {
346 for family in FAMILIES {
347 if !family.suffixes.iter().any(|suffix| file.ends_with(suffix)) {
348 continue;
349 }
350 match family.opener {
351 Opener::Line => {
352 let code = line.trim_start();
353 if let Some(form) = family
354 .forms
355 .iter()
356 .find(|form| code.starts_with(form.token))
357 {
358 return Some((0, form));
359 }
360 }
361 Opener::After(opener) => {
362 let Some(index) = comment_start(line, opener, quote_marks(file)) else {
363 continue;
364 };
365 let comment = &line[index..];
366 if let Some(form) = family
367 .forms
368 .iter()
369 .find(|form| comment_carries(comment, opener, form.token))
370 {
371 return Some((index, form));
372 }
373 }
374 }
375 }
376 None
377}
378
379fn fence_toggles<'a>(line: &'a str, fences: &[&'a str], comment: Option<&str>) -> Vec<&'a str> {
385 let mut out = Vec::new();
386 let mut open: Option<char> = None;
387 let mut escaped = false;
388 let mut skip_to = 0usize;
389 for (index, character) in line.char_indices() {
390 if index < skip_to {
391 continue;
392 }
393 if escaped {
394 escaped = false;
395 continue;
396 }
397 if open.is_none() {
398 if let Some(fence) = fences
399 .iter()
400 .find(|fence| line[index..].starts_with(**fence))
401 {
402 out.push(*fence);
403 skip_to = index + fence.len();
404 continue;
405 }
406 if comment.is_some_and(|opener| line[index..].starts_with(opener)) {
407 break;
408 }
409 }
410 match (open, character) {
411 (_, '\\') => escaped = true,
412 (None, '"' | '\'' | '`') => open = Some(character),
413 (Some(quote), character) if character == quote => open = None,
414 _ => {}
415 }
416 }
417 out
418}
419
420fn find_unescaped(line: &str, fence: &str) -> Option<usize> {
423 let mut escaped = false;
424 for (index, character) in line.char_indices() {
425 if escaped {
426 escaped = false;
427 continue;
428 }
429 if character == '\\' {
430 escaped = true;
431 continue;
432 }
433 if line[index..].starts_with(fence) {
434 return Some(index);
435 }
436 }
437 None
438}
439
440fn live_from(file: &str, lines: &[&str]) -> Vec<Option<usize>> {
447 let fences: &[&str] = if is_python(file) {
448 &["\"\"\"", "'''"]
449 } else if comment_opener(file) == Some("//") && !is_rust(file) {
450 &["`"]
451 } else {
452 return vec![Some(0); lines.len()];
453 };
454 let comment = comment_opener(file);
455 let mut open: Option<&str> = None;
456 lines
457 .iter()
458 .map(|line| {
459 let Some(fence) = open else {
460 for opened in fence_toggles(line, fences, comment) {
461 open = match open {
462 None => Some(opened),
463 Some(current) if current == opened => None,
464 Some(current) => Some(current),
465 };
466 }
467 return Some(0);
468 };
469 let closes = find_unescaped(line, fence);
470 if closes.is_some() {
471 open = None;
472 }
473 closes.map(|index| index + fence.len())
474 })
475 .collect()
476}
477
478fn is_closing(line: &str) -> bool {
479 line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
480}
481
482const fn on_a_boundary(text: &str, index: usize) -> bool {
485 index == 0
486 || !text.as_bytes()[index - 1].is_ascii_alphanumeric()
487 && text.as_bytes()[index - 1] != b'-'
488 && text.as_bytes()[index - 1] != b'_'
489}
490
491fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
492 line.match_indices("KI-")
493 .filter(|(index, _)| on_a_boundary(line, *index))
494 .filter_map(|(index, _)| {
495 let rest = &line[index + 3..];
496 let slug: String = rest
497 .chars()
498 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
499 .collect();
500 let closes = rest[slug.len()..]
503 .chars()
504 .next()
505 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
506 (!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
507 })
508}
509
510fn permanent_reason(line: &str) -> Option<String> {
518 let after = line
519 .match_indices(MARKER)
520 .filter(|(index, _)| on_a_boundary(line, *index))
521 .map(|(index, _)| &line[index + MARKER.len()..])
522 .find(|after| after.is_empty() || after.starts_with(char::is_whitespace))?;
523 let rest = after
524 .trim_end()
525 .trim_end_matches("-->")
526 .trim_end_matches("*/")
527 .trim();
528 Some(rest.to_string())
529}
530
531fn raw_opener(text: &str, index: usize) -> Option<(usize, usize)> {
537 let after = text[index..].strip_prefix('r')?;
538 let hashes = after.chars().take_while(|c| *c == '#').count();
539 after[hashes..].starts_with('"').then_some((
540 1 + hashes + 1,
542 hashes,
543 ))
544}
545
546fn literal_end(text: &str, index: usize) -> Option<usize> {
553 if let Some((opener, hashes)) = raw_opener(text, index) {
554 let body = index + opener;
555 let close = format!("\"{}", "#".repeat(hashes));
556 return Some(
557 text[body..]
558 .find(&close)
559 .map_or(text.len(), |at| body + at + close.len()),
560 );
561 }
562 let quote = text[index..]
563 .chars()
564 .next()
565 .filter(|c| *c == '"' || *c == '\'')?;
566 let body = index + quote.len_utf8();
567 let mut escaped = false;
568 for (at, character) in text[body..].char_indices() {
569 if escaped {
570 escaped = false;
571 continue;
572 }
573 match character {
574 '\\' => escaped = true,
575 c if c == quote => return Some(body + at + c.len_utf8()),
576 _ => {}
577 }
578 }
579 Some(text.len())
580}
581
582fn from_code(digits: &str, base: u32) -> Option<char> {
584 u32::from_str_radix(digits, base)
585 .ok()
586 .and_then(char::from_u32)
587}
588
589fn code_digits(body: &mut std::str::Chars, first: Option<char>, width: usize, base: u32) -> String {
591 let mut digits: String = first.into_iter().collect();
592 while digits.len() < width {
593 match body.clone().next().filter(|c| c.is_digit(base)) {
594 Some(next) => {
595 body.next();
596 digits.push(next);
597 }
598 None => break,
599 }
600 }
601 digits
602}
603
604fn push_escaped(out: &mut String, body: &mut std::str::Chars) {
618 let Some(character) = body.next() else { return };
619 match character {
620 'n' => out.push('\n'),
621 'r' => out.push('\r'),
622 't' => out.push('\t'),
623 'f' => out.push('\u{0c}'),
624 'v' => out.push('\u{0b}'),
625 'a' => out.push('\u{07}'),
626 'b' => out.push('\u{08}'),
627 digit @ '0'..='7' => out.extend(from_code(&code_digits(body, Some(digit), 3, 8), 8)),
630 'x' => out.extend(from_code(&code_digits(body, None, 2, 16), 16)),
631 'N' if body.clone().next() == Some('{') => {
634 body.take_while(|c| *c != '}').for_each(drop);
635 }
636 'u' | 'U' => {
638 let digits = if body.clone().next() == Some('{') {
639 body.next();
640 body.take_while(|c| *c != '}').collect()
641 } else {
642 code_digits(body, None, if character == 'u' { 4 } else { 8 }, 16)
643 };
644 out.extend(from_code(&digits, 16));
645 }
646 other => out.push(other),
647 }
648}
649
650fn quoted_from(text: &str, from: usize) -> Option<String> {
657 let start = from + text[from..].len() - text[from..].trim_start().len();
658 let rest = &text[start..];
659 if let Some((opener, hashes)) = raw_opener(rest, 0) {
660 let close = format!("\"{}", "#".repeat(hashes));
661 return rest[opener..]
662 .find(&close)
663 .map(|at| rest[opener..opener + at].to_string());
664 }
665 let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?;
666 let mut body = rest[quote.len_utf8()..].chars();
667 let mut out = String::new();
668 while let Some(character) = body.next() {
669 match character {
670 '\\' => push_escaped(&mut out, &mut body),
671 c if c == quote => return Some(out),
672 c => out.push(c),
673 }
674 }
675 None
676}
677
678fn extent_from<'a>(code: &'a str, token: &str) -> Option<&'a str> {
686 let start = code.find(token)?;
687 let text = &code[start..];
688 let mut depth = 0i32;
689 let mut opened = false;
690 let mut index = 0usize;
691 while let Some(character) = text[index..].chars().next() {
692 if let Some(end) = literal_end(text, index) {
693 index = end;
694 continue;
695 }
696 match character {
697 '(' | '[' => {
698 depth += 1;
699 opened = true;
700 }
701 ')' | ']' => {
702 depth -= 1;
703 if opened && depth <= 0 {
704 return Some(&code[start..start + index + character.len_utf8()]);
705 }
706 }
707 _ => {}
708 }
709 index += character.len_utf8();
710 }
711 opened.then_some(&code[start..])
714}
715
716fn arguments(extent: &str) -> Vec<&str> {
718 let Some(open) = extent.find('(') else {
719 return Vec::new();
720 };
721 let mut out = Vec::new();
722 let mut depth = 0i32;
723 let mut from = open + 1;
724 let mut at = open + 1;
725 while let Some(character) = extent[at..].chars().next() {
726 if let Some(end) = literal_end(extent, at) {
727 at = end;
728 continue;
729 }
730 match character {
731 '(' | '[' | '{' => depth += 1,
732 ')' | ']' | '}' if depth > 0 => depth -= 1,
733 ')' => {
734 out.push(&extent[from..at]);
735 return out;
736 }
737 ',' if depth == 0 => {
738 out.push(&extent[from..at]);
739 from = at + 1;
740 }
741 _ => {}
742 }
743 at += character.len_utf8();
744 }
745 out
746}
747
748fn keyword_value(argument: &str) -> Option<String> {
754 let after = argument.trim_start().strip_prefix("reason")?;
755 let equals = after
756 .find('=')
757 .filter(|at| after[..*at].trim().is_empty())?;
758 if after[equals + 1..].starts_with('=') {
760 return None;
761 }
762 quoted_from(after, equals + 1)
763}
764
765fn reason_argument(extent: &str) -> Option<String> {
771 arguments(extent)
772 .iter()
773 .find_map(|argument| keyword_value(argument))
774}
775
776fn positional_reason(extent: &str, slot: usize) -> Option<String> {
779 reason_argument(extent).or_else(|| quoted_from(arguments(extent).get(slot)?, 0))
780}
781
782fn native_reason(file: &str, channel: Channel, token: &str, region: &[&str]) -> Option<String> {
791 fn trailing(text: &str) -> String {
792 text.trim_end()
793 .trim_end_matches("-->")
794 .trim_end_matches("*/")
795 .trim()
796 .to_string()
797 }
798 match channel {
799 Channel::None => None,
800 Channel::AfterBracket => {
801 let line = region.first()?;
802 let at = line.find(token)?;
803 let close = line[at..].find(']')?;
804 Some(trailing(&line[at + close + 1..]))
805 }
806 Channel::AfterSeparator => {
809 let line = region.first()?;
810 let at = line.find(token)?;
811 let separator = line[at..].find(" -- ")?;
812 Some(trailing(&line[at + separator + 4..]))
813 }
814 Channel::ReasonArgument
815 | Channel::ValueString
816 | Channel::FirstArgument
817 | Channel::SecondArgument => {
818 let code: Vec<&str> = region.iter().map(|line| code_region(file, line)).collect();
819 let joined = code.join("\n");
820 let extent = extent_from(&joined, token)?;
821 match channel {
822 Channel::ReasonArgument => reason_argument(extent),
823 Channel::ValueString => {
824 let equals = extent.find('=')?;
825 quoted_from(extent, equals + 1)
826 }
827 Channel::FirstArgument => positional_reason(extent, 0),
830 _ => positional_reason(extent, 1),
831 }
832 }
833 }
834}
835
836fn looks_binary(bytes: &[u8]) -> bool {
837 bytes.iter().take(4096).any(|&b| b == 0)
838}
839
840#[derive(Clone, Copy, PartialEq, Eq)]
846enum Disposition {
847 Missing,
850 KnownIssue,
852 Accepted,
855 Conflict,
857 MarkerWithoutReason,
859}
860
861struct Site {
863 file: String,
864 kind: String,
867 number: usize,
868 line: String,
869 annotation: Vec<String>,
870 region_from: usize,
873 form: &'static Form,
874}
875
876impl Site {
877 fn cites_a_case(&self) -> bool {
878 self.annotation
879 .iter()
880 .any(|line| cited_cases(line).next().is_some())
881 }
882
883 fn marker_reason(&self) -> Option<String> {
885 self.annotation
886 .iter()
887 .find_map(|line| permanent_reason(line))
888 }
889
890 fn native_reason(&self) -> Option<String> {
893 let region: Vec<&str> = self.annotation[self.region_from..]
894 .iter()
895 .map(String::as_str)
896 .collect();
897 native_reason(&self.kind, self.form.channel, self.form.token, ®ion)
898 .filter(|reason| !reason.trim().is_empty())
899 }
900
901 fn disposition(&self) -> Disposition {
905 match (self.cites_a_case(), self.marker_reason()) {
906 (true, Some(_)) => Disposition::Conflict,
907 (true, None) => Disposition::KnownIssue,
908 (false, Some(reason)) if reason.is_empty() => Disposition::MarkerWithoutReason,
909 (false, Some(_)) => Disposition::Accepted,
910 (false, None) if self.native_reason().is_some() => Disposition::Accepted,
911 (false, None) => Disposition::Missing,
912 }
913 }
914}
915
916fn unbalanced(text: &str) -> i32 {
917 let mut depth = 0i32;
918 let mut open: Option<u8> = None;
919 let mut escaped = false;
920 for byte in text.bytes() {
921 if escaped {
922 escaped = false;
923 continue;
924 }
925 match (open, byte) {
926 (_, b'\\') => escaped = true,
927 (None, b'"' | b'\'') => open = Some(byte),
928 (Some(quote), byte) if byte == quote => open = None,
929 (None, b'(' | b'[') => depth += 1,
930 (None, b')' | b']') => depth -= 1,
931 _ => {}
932 }
933 }
934 depth
935}
936
937fn annotation(file: &str, lines: &[&str], index: usize, start: usize) -> (Vec<String>, usize) {
949 let mut out = Vec::new();
950 if let Some(opener) = comment_opener(file) {
951 if let Some(above) = index
952 .checked_sub(1)
953 .map(|previous| lines[previous].trim_start())
954 .filter(|previous| previous.starts_with(opener))
955 {
956 out.push(above.to_string());
957 }
958 }
959 let region_from = out.len();
960 out.push(lines[index][start..].to_string());
961 let mut depth = unbalanced(code_region(file, lines[index]));
965 let mut continuation = Vec::new();
966 let mut next = index + 1;
967 while depth > 0 && next < lines.len() {
968 continuation.push(lines[next].to_string());
969 depth += unbalanced(code_region(file, lines[next]));
970 next += 1;
971 }
972 if depth == 0 {
973 out.extend(continuation);
974 }
975 (out, region_from)
976}
977
978fn sites(ctx: &GateCtx) -> Result<Vec<Site>, GateError> {
979 let mut sites = Vec::new();
980 for file in walk_files(ctx) {
981 if file
982 .components()
983 .any(|part| part.as_str() == "known-issues")
984 {
985 continue;
986 }
987 let bytes =
988 std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
989 if looks_binary(&bytes) {
990 continue;
991 }
992 let Ok(text) = String::from_utf8(bytes) else {
993 continue;
994 };
995 let name = file.as_str().trim_start_matches("./").to_string();
996 let lines: Vec<&str> = text.lines().collect();
997 #[allow(clippy::case_sensitive_file_extension_comparisons)]
1003 let kinds = if name.ends_with(".md") {
1004 classify(&text)
1005 } else {
1006 Vec::new()
1007 };
1008 let kind = kind_name(&name, lines.first().copied().unwrap_or_default());
1012 let live = live_from(&kind, &lines);
1013 for (index, line) in lines.iter().enumerate() {
1014 if matches!(
1015 kinds.get(index),
1016 Some(LineKind::Fence | LineKind::FrontMatter)
1017 ) {
1018 continue;
1019 }
1020 let Some(offset) = live[index] else { continue };
1021 let Some((found, form)) = suppression_at(&kind, &line[offset..]) else {
1022 continue;
1023 };
1024 let start = offset + found;
1025 if !is_closing(line) {
1026 let (annotation, region_from) = annotation(&kind, &lines, index, start);
1027 sites.push(Site {
1028 file: name.clone(),
1029 kind: kind.clone(),
1030 number: index + 1,
1031 line: (*line).to_string(),
1032 annotation,
1033 region_from,
1034 form,
1035 });
1036 }
1037 }
1038 }
1039 Ok(sites)
1040}
1041
1042pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
1048 let sites = sites(ctx)?;
1049 let mut violations = Vec::new();
1050
1051 let mut caseless = Vec::new();
1052 for site in &sites {
1053 match site.disposition() {
1054 Disposition::KnownIssue | Disposition::Accepted => {}
1055 Disposition::MarkerWithoutReason => {
1056 violations.push(Violation::Finding(Finding::on_line(
1057 PERMANENT,
1058 &site.file,
1059 site.number,
1060 "the permanent marker states no reason",
1061 )));
1062 }
1063 Disposition::Conflict => violations.push(Violation::Finding(Finding::on_line(
1064 PERMANENT,
1065 &site.file,
1066 site.number,
1067 "names a case and states a permanent exception",
1068 ))),
1069 Disposition::Missing => caseless.push(site),
1070 }
1071 }
1072 if !caseless.is_empty() {
1073 violations.push(Violation::Finding(Finding::global(CASE, "")));
1074 for site in caseless {
1075 violations.push(Violation::Note(format!(
1076 "./{}:{}:{}",
1077 site.file, site.number, site.line
1078 )));
1079 }
1080 }
1081
1082 let known: BTreeSet<String> = ki_records(ctx, args)?
1083 .iter()
1084 .filter_map(|record| {
1085 record
1086 .file_name()
1087 .map(|name| name.trim_end_matches(".md").to_string())
1088 })
1089 .collect();
1090 let cited: BTreeSet<String> = sites
1091 .iter()
1092 .flat_map(|site| site.annotation.iter().flat_map(|line| cited_cases(line)))
1093 .collect();
1094 for case in cited {
1095 if !known.contains(&case) {
1096 violations.push(Violation::Finding(Finding::global(
1097 CASE,
1098 format!("{case} resolves to no record"),
1099 )));
1100 }
1101 }
1102 Ok(violations)
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108
1109 fn fixture() -> tempfile::TempDir {
1110 let dir = tempfile::tempdir().unwrap();
1111 let records = dir.path().join("_docs/reference/known-issues");
1112 std::fs::create_dir_all(&records).unwrap();
1113 std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
1114 dir
1115 }
1116
1117 fn run_on(name: &str, text: &str) -> Vec<String> {
1118 let dir = fixture();
1119 std::fs::write(dir.path().join(name), text).unwrap();
1120 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1121 run(&ctx, &[])
1122 .unwrap()
1123 .iter()
1124 .map(ToString::to_string)
1125 .collect()
1126 }
1127
1128 #[test]
1129 fn a_repository_without_suppressions_passes() {
1130 let dir = fixture();
1131 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1132 assert!(run(&ctx, &[]).unwrap().is_empty());
1133 }
1134
1135 #[test]
1136 fn every_form_passes_when_it_names_a_record() {
1137 for (name, text) in [
1138 (
1139 "local.md",
1140 "<!-- markdownlint-disable MD013 KI-vendor-quirk -->\n",
1141 ),
1142 ("local.md", "<!-- dprint-ignore KI-vendor-quirk -->\n"),
1143 ("local.rs", "#[allow(dead_code)] // KI-vendor-quirk\n"),
1144 ("local.rs", "#[expect(dead_code)] // KI-vendor-quirk\n"),
1145 ("local.rs", "#[ignore = \"KI-vendor-quirk\"]\n"),
1146 (
1147 "local.sh",
1148 "# shellcheck disable=SC2329 # KI-vendor-quirk\n",
1149 ),
1150 ("local.py", "x = 1 # noqa: E501 KI-vendor-quirk\n"),
1151 ("local.py", "x = 1 # type: ignore KI-vendor-quirk\n"),
1152 (
1153 "local.yml",
1154 "on: push # zizmor: ignore[dangerous-triggers] KI-vendor-quirk\n",
1155 ),
1156 (
1157 "local.ts",
1158 "// eslint-disable-next-line no-eval KI-vendor-quirk\n",
1159 ),
1160 ] {
1161 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1162 }
1163 }
1164
1165 #[test]
1166 fn every_form_fails_when_it_says_nothing() {
1167 for (name, text) in [
1168 ("local.md", "<!-- markdownlint-disable MD013 -->\n"),
1169 ("local.rs", "#[allow(dead_code)]\n"),
1170 ("local.rs", "#![allow(clippy::unwrap_used)]\n"),
1171 ("local.sh", "# shellcheck disable=SC2329\n"),
1172 ("local.py", "x = 1 # noqa: E501\n"),
1173 ("local.ts", "// eslint-disable-next-line no-eval\n"),
1174 ] {
1175 let out = run_on(name, text);
1176 assert_eq!(out.len(), 2, "{name}: {text}");
1177 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1178 assert!(out[1].contains(&format!("{name}:1")));
1179 }
1180 }
1181
1182 #[test]
1183 fn a_permanent_marker_with_a_reason_passes() {
1184 for (name, text) in [
1185 (
1186 "local.rs",
1187 "// sdd: permanent the braces are a template placeholder\n#[allow(clippy::x)]\n",
1188 ),
1189 (
1190 "local.rs",
1191 "#[allow(clippy::x)] // sdd: permanent the lint is wrong here\n",
1192 ),
1193 (
1194 "local.sh",
1195 "# shellcheck disable=SC2329 # sdd: permanent reached through a trap\n",
1196 ),
1197 (
1198 "local.md",
1199 "<!-- markdownlint-disable MD013 sdd: permanent the table is data -->\n",
1200 ),
1201 ] {
1202 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1203 }
1204 }
1205
1206 #[test]
1207 fn a_tools_own_reason_states_a_permanent_exception() {
1208 for (name, text) in [
1209 (
1210 "local.yml",
1211 "on: push # zizmor: ignore[dangerous-triggers] the definition is the trusted one\n",
1212 ),
1213 (
1214 "local.rs",
1215 "#[allow(dead_code, reason = \"the field is the wire format\")]\n",
1216 ),
1217 (
1218 "local.rs",
1219 "#[expect(dead_code, reason = \"the field is the wire format\")]\n",
1220 ),
1221 (
1222 "local.rs",
1223 "#[ignore = \"the fixture needs a live network\"]\n",
1224 ),
1225 (
1226 "local.py",
1227 "@pytest.mark.xfail(reason=\"the parser rejects a valid literal\", strict=True)\ndef test_x():\n pass\n",
1228 ),
1229 (
1230 "local.ts",
1231 "// eslint-disable-next-line no-eval -- the input is a literal in this file\n",
1232 ),
1233 ] {
1234 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1235 }
1236 }
1237
1238 #[test]
1241 fn a_generated_workflow_states_its_reason_in_its_own_idiom() {
1242 let text = concat!(
1243 "on:\n",
1244 " # zizmor: ignore[dangerous-triggers] the trigger is what makes this gate\n",
1245 " # unforgeable, and the header above states why it is safe here.\n",
1246 " pull_request_target:\n",
1247 );
1248 assert!(run_on("local.yml", text).is_empty());
1249 }
1250
1251 #[test]
1252 fn a_tools_own_reason_left_empty_is_no_reason() {
1253 for (name, text) in [
1254 (
1255 "local.yml",
1256 "on: push # zizmor: ignore[dangerous-triggers]\n",
1257 ),
1258 ("local.rs", "#[allow(dead_code, reason = \"\")]\n"),
1259 ("local.rs", "#[ignore]\n"),
1260 ("local.ts", "// eslint-disable-next-line no-eval --\n"),
1261 ] {
1262 let out = run_on(name, text);
1263 assert_eq!(out.len(), 2, "{name}: {text}");
1264 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1265 }
1266 }
1267
1268 #[test]
1269 fn a_tool_that_defines_no_reason_position_still_takes_the_marker() {
1270 for (name, text) in [
1271 ("local.py", "x = 1 # noqa: E501 the line is one URL\n"),
1272 (
1273 "local.sh",
1274 "# shellcheck disable=SC2329 reached through a trap\n",
1275 ),
1276 (
1277 "local.md",
1278 "<!-- markdownlint-disable MD013 the table is data -->\n",
1279 ),
1280 ] {
1281 let out = run_on(name, text);
1282 assert_eq!(out.len(), 2, "{name}: {text}");
1283 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1284 }
1285 }
1286
1287 #[test]
1288 fn prose_above_a_suppression_is_not_the_tools_own_reason() {
1289 for (name, text) in [
1290 (
1291 "local.rs",
1292 "// reason = \"this comment is not the attribute\"\n#[allow(dead_code)]\n",
1293 ),
1294 (
1295 "local.rs",
1296 "#[allow(dead_code)] // reason = \"this comment is not the attribute\"\n",
1297 ),
1298 ] {
1299 let out = run_on(name, text);
1300 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1301 }
1302 }
1303
1304 #[test]
1307 fn a_reason_beside_a_suppression_is_not_the_suppressions() {
1308 for (name, text) in [
1309 (
1310 "local.rs",
1311 "#[allow(dead_code)] #[doc = \"reason = 'unrelated prose'\"] fn f() {}\n",
1312 ),
1313 (
1314 "local.rs",
1315 "#[allow(dead_code)] #[expect(unused, reason = \"the other one states it\")]\n",
1316 ),
1317 ] {
1318 let out = run_on(name, text);
1319 assert_eq!(
1320 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1321 "{name}: {text}"
1322 );
1323 }
1324 }
1325
1326 #[test]
1327 fn a_whitespace_reason_states_nothing() {
1328 for (name, text) in [
1329 ("local.rs", "#[allow(dead_code, reason = \" \")]\n"),
1330 ("local.rs", "#[ignore = \" \"]\n"),
1331 (
1332 "local.py",
1333 "@pytest.mark.skip(reason=\" \")\ndef test_x():\n pass\n",
1334 ),
1335 (
1336 "local.py",
1337 "@unittest.skip(\" \")\ndef test_x():\n pass\n",
1338 ),
1339 ] {
1340 let out = run_on(name, text);
1341 assert_eq!(
1342 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1343 "{name}: {text}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn a_raw_string_reason_is_a_reason() {
1350 for text in [
1351 "#[ignore = r\"requires a live service\"]\n",
1352 "#[ignore = r#\"requires a \"live\" service\"#]\n",
1353 "#[allow(dead_code, reason = r\"the field is the wire format\")]\n",
1354 ] {
1355 assert!(run_on("local.rs", text).is_empty(), "{text}");
1356 }
1357 }
1358
1359 #[test]
1360 fn a_positional_skip_reason_is_a_reason() {
1361 for text in [
1362 "@unittest.skip(\"the service is unavailable\")\ndef test_x():\n pass\n",
1363 "@unittest.skipIf(sys.platform == \"win32\", \"the path is posix only\")\ndef test_x():\n pass\n",
1364 "@unittest.skipUnless(os.name == \"posix\", \"the path is posix only\")\ndef test_x():\n pass\n",
1365 ] {
1366 assert!(run_on("local.py", text).is_empty(), "{text}");
1367 }
1368 }
1369
1370 #[test]
1373 fn a_skip_condition_is_not_its_reason() {
1374 let out = run_on(
1375 "local.py",
1376 "@unittest.skipIf(sys.platform == \"win32\")\ndef test_x():\n pass\n",
1377 );
1378 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1379 }
1380
1381 #[test]
1384 fn a_keyword_skip_reason_is_a_reason() {
1385 for text in [
1386 "@unittest.skip(reason=\"the service is unavailable\")\ndef test_x():\n pass\n",
1387 "@unittest.skipIf(condition=True, reason=\"the path is posix only\")\ndef test_x():\n pass\n",
1388 "@unittest.skipUnless(reason=\"the path is posix only\", condition=True)\ndef test_x():\n pass\n",
1389 ] {
1390 assert!(run_on("local.py", text).is_empty(), "{text}");
1391 }
1392 }
1393
1394 #[test]
1397 fn an_escaped_whitespace_reason_states_nothing() {
1398 for text in [
1399 "#[allow(dead_code, reason = \"\\t\")]\n",
1400 "#[allow(dead_code, reason = \"\\n\\r\")]\n",
1401 ] {
1402 let out = run_on("local.rs", text);
1403 assert_eq!(
1404 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1405 "{text}"
1406 );
1407 }
1408 }
1409
1410 #[test]
1413 fn a_raw_reason_carrying_a_delimiter_is_still_one_literal() {
1414 let text = "#[allow(dead_code, reason = r#\"the token \")]\" is data\"#)]\n";
1415 assert!(run_on("local.rs", text).is_empty());
1416 }
1417
1418 #[test]
1420 fn a_nested_call_does_not_lend_its_reason() {
1421 for (name, text) in [
1422 (
1423 "local.py",
1424 "@unittest.skipIf(condition=check(reason=\"borrowed\"), reason=\"\")\ndef test_x():\n pass\n",
1425 ),
1426 (
1427 "local.py",
1428 "@pytest.mark.skip(reason=compute(reason=\"borrowed\"))\ndef test_x():\n pass\n",
1429 ),
1430 ] {
1431 let out = run_on(name, text);
1432 assert_eq!(
1433 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1434 "{name}: {text}"
1435 );
1436 }
1437 }
1438
1439 #[test]
1441 fn a_numeric_whitespace_escape_states_nothing() {
1442 for text in [
1443 "#[allow(dead_code, reason = \"\\x20\")]\n",
1444 "#[allow(dead_code, reason = \"\\u{20}\")]\n",
1445 "#[allow(dead_code, reason = \"\\u{20}\\t\\x20\")]\n",
1446 ] {
1447 let out = run_on("local.rs", text);
1448 assert_eq!(
1449 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1450 "{text}"
1451 );
1452 }
1453 }
1454
1455 #[test]
1458 fn every_whitespace_escape_states_nothing() {
1459 for (name, text) in [
1460 (
1461 "local.py",
1462 "@unittest.skip(\"\\v\")\ndef test_x():\n pass\n",
1463 ),
1464 (
1465 "local.py",
1466 "@unittest.skip(\"\\f\")\ndef test_x():\n pass\n",
1467 ),
1468 (
1469 "local.py",
1470 "@unittest.skip(\"\\040\")\ndef test_x():\n pass\n",
1471 ),
1472 (
1473 "local.py",
1474 "@unittest.skip(\"\\u0020\")\ndef test_x():\n pass\n",
1475 ),
1476 (
1477 "local.py",
1478 "@unittest.skip(\"\\N{SPACE}\")\ndef test_x():\n pass\n",
1479 ),
1480 ] {
1481 let out = run_on(name, text);
1482 assert_eq!(
1483 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1484 "{name}: {text}"
1485 );
1486 }
1487 }
1488
1489 #[test]
1492 fn a_numeric_escape_inside_a_reason_keeps_it() {
1493 let text = "#[allow(dead_code, reason = \"the\\x20field is the wire format\")]\n";
1494 assert!(run_on("local.rs", text).is_empty());
1495 let named =
1496 "@unittest.skip(\"\\N{BULLET} the service is unavailable\")\ndef test_x():\n pass\n";
1497 assert!(run_on("local.py", named).is_empty());
1498 }
1499
1500 #[test]
1502 fn a_longer_name_is_not_the_reason_parameter() {
1503 let out = run_on(
1504 "local.py",
1505 "@unittest.skipIf(reason_code == \"x\", 12)\ndef test_x():\n pass\n",
1506 );
1507 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1508 }
1509
1510 #[test]
1511 fn a_case_inside_a_tools_own_reason_stays_a_known_issue() {
1512 assert!(
1513 run_on(
1514 "local.rs",
1515 "#[allow(dead_code, reason = \"KI-vendor-quirk\")]\n"
1516 )
1517 .is_empty()
1518 );
1519 let out = run_on("local.rs", "#[allow(dead_code, reason = \"KI-absent\")]\n");
1520 assert_eq!(
1521 out[0],
1522 "FAIL spec-to-code:a-suppression-names-its-case: KI-absent resolves to no record"
1523 );
1524 }
1525
1526 #[test]
1527 fn a_marker_on_the_line_above_a_multi_line_attribute_passes() {
1528 let text = "// sdd: permanent a test module panics as its failure signal\n#![allow(\n clippy::unwrap_used\n)]\n";
1529 assert!(run_on("local.rs", text).is_empty());
1530 }
1531
1532 #[test]
1533 fn a_non_comment_line_above_supplies_nothing() {
1534 let text = "let reason = \"KI-vendor-quirk\";\n#[allow(dead_code)]\n";
1535 let out = run_on("local.rs", text);
1536 assert_eq!(out.len(), 2);
1537 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1538 }
1539
1540 #[test]
1541 fn a_marker_without_a_reason_is_rejected() {
1542 for text in [
1543 "#[allow(dead_code)] // sdd: permanent\n",
1544 "// sdd: permanent\n#[allow(dead_code)]\n",
1545 ] {
1546 let out = run_on("local.rs", text);
1547 assert_eq!(out.len(), 1, "{text}");
1548 assert!(
1549 out[0].ends_with(": the permanent marker states no reason"),
1550 "{text}"
1551 );
1552 }
1553 }
1554
1555 #[test]
1556 fn an_expected_failure_is_a_suppression() {
1557 assert!(
1558 run_on(
1559 "test_x.py",
1560 "@pytest.mark.xfail(reason=\"KI-vendor-quirk\", strict=True)\ndef test_x():\n pass\n"
1561 )
1562 .is_empty()
1563 );
1564 let out = run_on(
1565 "test_x.py",
1566 "@pytest.mark.xfail(strict=True)\ndef test_x():\n pass\n",
1567 );
1568 assert_eq!(out.len(), 2);
1569 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1570 }
1571
1572 #[test]
1573 fn a_form_inside_a_string_is_a_quotation() {
1574 for (name, text) in [
1575 ("local.py", "value = \"# noqa: E501\"\n"),
1576 ("local.sh", "printf '%s' '# shellcheck disable=SC2329'\n"),
1577 (
1578 "local.ts",
1579 "const form = \"// eslint-disable-next-line\";\n",
1580 ),
1581 ] {
1582 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1583 }
1584 }
1585
1586 #[test]
1587 fn a_fenced_example_in_a_document_is_a_quotation() {
1588 for fence in ["```markdown", "~~~markdown", "````markdown"] {
1589 let close = fence.trim_end_matches("markdown");
1590 let text = format!(
1591 "# Chapter\n\n{fence}\n<!-- markdownlint-disable MD013 -->\n{close}\n\nProse.\n"
1592 );
1593 assert!(run_on("chapter.md", &text).is_empty(), "{fence}");
1594 }
1595 }
1596
1597 #[test]
1598 fn an_apostrophe_before_a_live_directive_does_not_hide_it() {
1599 let out = run_on("local.py", "value = \"it's long\" # noqa: E501\n");
1600 assert_eq!(out.len(), 2);
1601 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1602 }
1603
1604 #[test]
1605 fn a_marker_inside_a_longer_word_is_not_the_marker() {
1606 let out = run_on(
1607 "local.py",
1608 "x = 1 # noqa: E501 not-sdd: permanent reason\n",
1609 );
1610 assert_eq!(out.len(), 2);
1611 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1612 }
1613
1614 #[test]
1615 fn a_case_written_in_code_before_the_comment_is_not_the_suppressions() {
1616 let out = run_on("local.py", "path = \"KI-vendor-quirk.md\" # noqa: E501\n");
1617 assert_eq!(out.len(), 2);
1618 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1619 }
1620
1621 #[test]
1622 fn a_multiline_expected_failure_carries_its_case() {
1623 let text = "@pytest.mark.xfail(\n reason=\"KI-vendor-quirk\",\n strict=True,\n)\ndef test_x():\n pass\n";
1624 assert!(run_on("test_x.py", text).is_empty());
1625 }
1626
1627 #[test]
1628 fn a_long_attribute_carries_its_case_past_any_line_count() {
1629 let lints = " clippy::a_lint,\n".repeat(20);
1630 let text = format!("#[allow(\n{lints} // KI-vendor-quirk\n)]\nfn f() {{}}\n");
1631 assert!(run_on("local.rs", &text).is_empty());
1632 }
1633
1634 #[test]
1635 fn an_apostrophe_in_comment_prose_does_not_hide_a_later_directive() {
1636 let out = run_on("local.py", "value = 1 # don't reflow # noqa: E501\n");
1637 assert_eq!(out.len(), 2);
1638 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1639 }
1640
1641 #[test]
1642 fn comment_punctuation_does_not_extend_the_annotation() {
1643 let text = "#[allow(dead_code)] // (\nfn kept() {} // KI-vendor-quirk\n";
1644 let out = run_on("local.rs", text);
1645 assert_eq!(out.len(), 2);
1646 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1647 }
1648
1649 #[test]
1650 fn a_line_carrying_multibyte_text_is_scanned_without_panicking() {
1651 assert!(run_on("local.py", "value = \"a 🤖 walks in\" # a note\n").is_empty());
1652 let out = run_on("local.py", "value = \"a 🤖 walks in\" # noqa: E501\n");
1653 assert_eq!(out.len(), 2);
1654 }
1655
1656 #[test]
1657 fn a_form_inside_a_multiline_string_is_a_quotation() {
1658 for (name, text) in [
1659 ("local.py", "DOC = \"\"\"\n# noqa: E501\n\"\"\"\n"),
1660 (
1661 "local.ts",
1662 "const doc = `\n// eslint-disable-next-line\n`;\n",
1663 ),
1664 ("local.ts", "const doc = `// eslint-disable-next-line`;\n"),
1665 ] {
1666 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1667 }
1668 }
1669
1670 #[test]
1671 fn a_linters_other_spellings_are_the_same_form() {
1672 for text in [
1673 "value = 1 # NOQA: E501\n",
1674 "# ruff: noqa\n",
1675 "# flake8: noqa\n",
1676 ] {
1677 let out = run_on("local.py", text);
1678 assert_eq!(out.len(), 2, "{text}");
1679 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1680 }
1681 }
1682
1683 #[test]
1684 fn the_line_a_multiline_string_closes_on_is_still_content() {
1685 for (name, text) in [
1686 ("local.py", "DOC = \"\"\"\n# noqa: E501 \"\"\"\n"),
1687 ("local.ts", "const doc = `\n// eslint-disable-next-line`;\n"),
1688 ] {
1689 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1690 }
1691 }
1692
1693 #[test]
1694 fn an_escaped_delimiter_closes_no_multiline_string() {
1695 let text = "const t = `\nconst label = \\`value\\`;\n// eslint-disable-next-line\n`;\n";
1696 assert!(run_on("local.ts", text).is_empty());
1697 }
1698
1699 #[test]
1700 fn a_suppression_after_a_closing_delimiter_is_live() {
1701 let out = run_on(
1702 "local.py",
1703 "DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501\n",
1704 );
1705 assert_eq!(out.len(), 2);
1706 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1707 assert!(out[1].contains("local.py:3"));
1708 let text = "DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501 KI-vendor-quirk\n";
1709 assert!(run_on("local.py", text).is_empty());
1710 }
1711
1712 #[test]
1713 fn a_quoted_fence_delimiter_opens_no_multiline_string() {
1714 for opener in [
1715 "delimiter = '\"\"\"'\n",
1716 "# a docstring opens with \"\"\"\n",
1717 ] {
1718 let out = run_on("local.py", &format!("{opener}value = 1 # noqa: E501\n"));
1719 assert_eq!(out.len(), 2, "{opener}");
1720 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1721 }
1722 }
1723
1724 #[test]
1725 fn a_token_that_runs_on_is_not_the_token() {
1726 let out = run_on(
1727 "local.py",
1728 "x = 1 # noqa: E501 sdd: permanently justified\n",
1729 );
1730 assert_eq!(out.len(), 2);
1731 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1732
1733 let out = run_on("local.rs", "#[allow(dead_code)] // KI-vendor-quirkXYZ\n");
1734 assert_eq!(out.len(), 2);
1735 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1736 }
1737
1738 #[test]
1739 fn a_case_and_a_marker_together_are_rejected() {
1740 let out = run_on(
1741 "local.rs",
1742 "#[allow(dead_code)] // KI-vendor-quirk sdd: permanent both\n",
1743 );
1744 assert_eq!(out.len(), 1);
1745 assert!(out[0].ends_with(": names a case and states a permanent exception"));
1746 }
1747
1748 #[test]
1749 fn a_case_resolving_to_no_record_is_rejected() {
1750 let out = run_on(
1751 "local.md",
1752 "<!-- markdownlint-disable KI-absent-record -->\n",
1753 );
1754 assert_eq!(
1755 out,
1756 vec![
1757 "FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
1758 .to_string()
1759 ]
1760 );
1761 }
1762
1763 #[test]
1764 fn a_form_named_outside_its_file_kind_is_a_quotation() {
1765 for (name, text) in [
1766 (
1767 "prose.md",
1768 "The `#[allow(dead_code)]` attribute suppresses a lint.\n",
1769 ),
1770 (
1771 "prose.md",
1772 "A Python file carries `# noqa: E501` at the line.\n",
1773 ),
1774 (
1775 "local.rs",
1776 "let form = \"<!-- markdownlint-disable -->\";\n",
1777 ),
1778 ("local.rs", "let form = \"# shellcheck disable=SC2329\";\n"),
1779 ("local.rs", "let form = \"// eslint-disable-next-line\";\n"),
1780 ] {
1781 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1782 }
1783 }
1784
1785 #[test]
1786 fn closing_markers_are_not_suppressions() {
1787 let text = "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n";
1788 assert!(run_on("local.md", text).is_empty());
1789 }
1790
1791 #[test]
1794 fn a_shebang_makes_a_suffixless_script_readable() {
1795 let out = run_on("publish", "#!/bin/bash\n# shellcheck disable=SC2086\n");
1796 assert_eq!(out.len(), 2);
1797 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1798 assert!(out[1].contains("publish:2"));
1799 }
1800
1801 #[test]
1802 fn a_suffixless_script_takes_the_marker_and_the_case() {
1803 for text in [
1804 "#!/bin/bash\n# shellcheck disable=SC2086 # sdd: permanent the word split is wanted\n",
1805 "#!/bin/bash\n# shellcheck disable=SC2086 # KI-vendor-quirk\n",
1806 ] {
1807 assert!(run_on("publish", text).is_empty(), "{text}");
1808 }
1809 }
1810
1811 #[test]
1814 fn a_finding_names_the_real_path_and_not_the_borrowed_suffix() {
1815 let out = run_on("publish", "#!/bin/bash\n# shellcheck disable=SC2086\n");
1816 assert!(out[1].contains("./publish:2"), "{}", out[1]);
1817 assert!(!out[1].contains("publish.sh"), "{}", out[1]);
1818 }
1819
1820 #[test]
1821 fn an_env_shebang_reads_the_same_as_a_direct_one() {
1822 let out = run_on(
1823 "publish",
1824 "#!/usr/bin/env bash\n# shellcheck disable=SC2086\n",
1825 );
1826 assert_eq!(out.len(), 2);
1827 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1828 }
1829
1830 #[test]
1833 fn a_file_without_a_suffix_and_without_a_shebang_is_skipped() {
1834 assert!(run_on("justfile", "check:\n # shellcheck disable=SC2086\n").is_empty());
1835 }
1836
1837 #[test]
1840 fn an_unlisted_interpreter_names_no_family() {
1841 assert!(run_on("run", "#!/usr/bin/perl\n# noqa: E501\n").is_empty());
1842 }
1843
1844 #[test]
1845 fn a_suffix_still_decides_where_the_file_has_one() {
1846 assert!(run_on("notes.txt", "# shellcheck disable=SC2086\n").is_empty());
1847 }
1848
1849 #[test]
1852 fn an_optional_argument_is_not_the_interpreter() {
1853 let out = run_on("publish", "#!/bin/bash -e\n# shellcheck disable=SC2086\n");
1854 assert_eq!(out.len(), 2);
1855 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1856 assert!(run_on("run", "#!/usr/bin/perl bash\n# noqa: E501\n").is_empty());
1857 }
1858
1859 #[test]
1862 fn an_env_shebang_reads_past_its_options_and_assignments() {
1863 for text in [
1864 "#!/usr/bin/env -S bash -e\n# shellcheck disable=SC2086\n",
1865 "#!/usr/bin/env -S LC_ALL=C bash\n# shellcheck disable=SC2086\n",
1866 "#!/usr/bin/env -S -u FOO bash\n# shellcheck disable=SC2086\n",
1867 "#!/usr/bin/env --unset FOO bash\n# shellcheck disable=SC2086\n",
1868 ] {
1869 let out = run_on("publish", text);
1870 assert_eq!(out.len(), 2, "{text}");
1871 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1872 }
1873 }
1874
1875 #[test]
1878 fn an_env_option_argument_is_not_the_command() {
1879 assert_eq!(
1880 kind_name("publish", "#!/usr/bin/env -u python3 bash"),
1881 "publish.sh"
1882 );
1883 assert_eq!(
1884 kind_name("publish", "#!/usr/bin/env -C /tmp python3"),
1885 "publish.py"
1886 );
1887 }
1888
1889 #[test]
1892 fn a_quoted_env_option_argument_is_one_argument() {
1893 assert_eq!(
1894 kind_name("publish", "#!/usr/bin/env -S -C \"/tmp dir\" bash"),
1895 "publish.sh"
1896 );
1897 assert_eq!(
1898 kind_name("publish", "#!/usr/bin/env -S -C '/tmp/dir' python3"),
1899 "publish.py"
1900 );
1901 }
1902
1903 #[test]
1906 fn a_dot_in_a_directory_is_not_a_suffix() {
1907 assert_eq!(kind_name("scripts.d/publish", "cmd\n"), "scripts.d/publish");
1908 assert_eq!(
1909 kind_name("scripts.d/publish", "#!/bin/sh"),
1910 "scripts.d/publish.sh"
1911 );
1912 assert_eq!(
1913 kind_name("scripts/publish.sh", "#!/usr/bin/env python3"),
1914 "scripts/publish.sh"
1915 );
1916 }
1917
1918 #[cfg(windows)]
1921 #[test]
1922 fn a_windows_separator_still_bounds_the_basename() {
1923 assert_eq!(
1924 kind_name("scripts.d\\publish", "#!/bin/sh"),
1925 "scripts.d\\publish.sh"
1926 );
1927 }
1928
1929 #[test]
1930 fn a_vendored_tree_is_skipped() {
1931 let dir = fixture();
1932 let vendored = dir.path().join("third-party/upstream");
1933 std::fs::create_dir_all(&vendored).unwrap();
1934 std::fs::write(vendored.join("hook.py"), "x = 1 # noqa: E501\n").unwrap();
1935 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1936 assert!(run(&ctx, &[]).unwrap().is_empty());
1937 }
1938}