1use std::collections::BTreeSet;
35
36use crate::domain::finding::Finding;
37use crate::domain::rule_id::RuleId;
38use crate::gates::markdown_prose::{LineKind, classify};
39use crate::gates::paths::ki_records;
40use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
41
42pub const CITES: &[RuleId] = &[
44 RuleId::SuppressionNamesItsCase,
45 RuleId::PermanentExceptionStatesItsReason,
46];
47
48const CASE: RuleId = RuleId::SuppressionNamesItsCase;
49const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
50
51const MARKER: &str = "sdd: permanent";
53
54#[derive(Clone, Copy, PartialEq, Eq)]
56enum Opener {
57 Line,
59 After(&'static str),
61}
62
63#[derive(Clone, Copy, PartialEq, Eq)]
69enum Channel {
70 None,
72 AfterBracket,
74 AfterSeparator,
76 ReasonArgument,
78 ValueString,
80 FirstArgument,
82 SecondArgument,
85}
86
87struct Form {
90 token: &'static str,
91 channel: Channel,
92}
93
94const fn form(token: &'static str, channel: Channel) -> Form {
95 Form { token, channel }
96}
97
98struct Family {
100 suffixes: &'static [&'static str],
101 opener: Opener,
102 forms: &'static [Form],
103}
104
105const FAMILIES: &[Family] = &[
106 Family {
107 suffixes: &[".rs"],
108 opener: Opener::Line,
109 forms: &[
110 form("#[allow(", Channel::ReasonArgument),
111 form("#[expect(", Channel::ReasonArgument),
112 form("#![allow(", Channel::ReasonArgument),
113 form("#![expect(", Channel::ReasonArgument),
114 form("#[ignore", Channel::ValueString),
115 ],
116 },
117 Family {
120 suffixes: &[".py"],
121 opener: Opener::Line,
122 forms: &[
123 form("@pytest.mark.xfail", Channel::ReasonArgument),
124 form("@pytest.mark.skip", Channel::ReasonArgument),
125 form("@unittest.skipIf", Channel::SecondArgument),
126 form("@unittest.skipUnless", Channel::SecondArgument),
127 form("@unittest.skip", Channel::FirstArgument),
128 form("@unittest.expectedFailure", Channel::None),
129 ],
130 },
131 Family {
132 suffixes: &[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"],
133 opener: Opener::After("#"),
134 forms: &[
135 form("shellcheck disable=", Channel::None),
136 form("noqa", Channel::None),
137 form("ruff: noqa", Channel::None),
138 form("flake8: noqa", Channel::None),
139 form("type: ignore", Channel::None),
140 form("zizmor: ignore[", Channel::AfterBracket),
141 ],
142 },
143 Family {
144 suffixes: &[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
145 opener: Opener::After("//"),
146 forms: &[form("eslint-disable", Channel::AfterSeparator)],
147 },
148 Family {
149 suffixes: &[".md", ".html"],
150 opener: Opener::After("<!--"),
151 forms: &[
152 form("dprint-ignore", Channel::None),
153 form("markdownlint-disable", Channel::None),
154 ],
155 },
156];
157
158fn comment_opener(file: &str) -> Option<&'static str> {
162 const OPENERS: &[(&[&str], &str)] = &[
163 (&[".rs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], "//"),
164 (&[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"], "#"),
165 ];
166 OPENERS
167 .iter()
168 .find(|(suffixes, _)| suffixes.iter().any(|suffix| file.ends_with(suffix)))
169 .map(|(_, opener)| *opener)
170}
171
172fn comment_start(line: &str, opener: &str, quotes: &[char]) -> Option<usize> {
178 let mut open: Option<char> = None;
179 let mut escaped = false;
180 for (index, character) in line.char_indices() {
181 if escaped {
182 escaped = false;
183 continue;
184 }
185 if open.is_none() && line[index..].starts_with(opener) {
186 return Some(index);
187 }
188 match (open, character) {
189 (_, '\\') => escaped = true,
190 (None, character) if quotes.contains(&character) => open = Some(character),
191 (Some(quote), character) if character == quote => open = None,
192 _ => {}
193 }
194 }
195 None
196}
197
198fn quote_marks(file: &str) -> &'static [char] {
201 if comment_opener(file) == Some("//") && !is_rust(file) {
202 &['"', '\'', '`']
203 } else {
204 &['"', '\'']
205 }
206}
207
208fn is_rust(file: &str) -> bool {
209 #[allow(clippy::case_sensitive_file_extension_comparisons)]
211 file.ends_with(".rs")
212}
213
214fn is_python(file: &str) -> bool {
215 #[allow(clippy::case_sensitive_file_extension_comparisons)]
217 file.ends_with(".py")
218}
219
220fn code_region<'a>(file: &str, line: &'a str) -> &'a str {
222 comment_opener(file)
223 .and_then(|opener| comment_start(line, opener, quote_marks(file)))
224 .map_or(line, |index| &line[..index])
225}
226
227fn comment_carries(comment: &str, opener: &str, token: &str) -> bool {
232 let lowered = comment.to_ascii_lowercase();
233 let mut start = 0usize;
234 while let Some(offset) = lowered[start..].find(opener) {
235 let index = start + offset;
236 if lowered[index + opener.len()..]
237 .trim_start_matches(' ')
238 .starts_with(token)
239 {
240 return true;
241 }
242 start = index + opener.len();
243 }
244 false
245}
246
247fn suppression_at(file: &str, line: &str) -> Option<(usize, &'static Form)> {
256 for family in FAMILIES {
257 if !family.suffixes.iter().any(|suffix| file.ends_with(suffix)) {
258 continue;
259 }
260 match family.opener {
261 Opener::Line => {
262 let code = line.trim_start();
263 if let Some(form) = family
264 .forms
265 .iter()
266 .find(|form| code.starts_with(form.token))
267 {
268 return Some((0, form));
269 }
270 }
271 Opener::After(opener) => {
272 let Some(index) = comment_start(line, opener, quote_marks(file)) else {
273 continue;
274 };
275 let comment = &line[index..];
276 if let Some(form) = family
277 .forms
278 .iter()
279 .find(|form| comment_carries(comment, opener, form.token))
280 {
281 return Some((index, form));
282 }
283 }
284 }
285 }
286 None
287}
288
289fn fence_toggles<'a>(line: &'a str, fences: &[&'a str], comment: Option<&str>) -> Vec<&'a str> {
295 let mut out = Vec::new();
296 let mut open: Option<char> = None;
297 let mut escaped = false;
298 let mut skip_to = 0usize;
299 for (index, character) in line.char_indices() {
300 if index < skip_to {
301 continue;
302 }
303 if escaped {
304 escaped = false;
305 continue;
306 }
307 if open.is_none() {
308 if let Some(fence) = fences
309 .iter()
310 .find(|fence| line[index..].starts_with(**fence))
311 {
312 out.push(*fence);
313 skip_to = index + fence.len();
314 continue;
315 }
316 if comment.is_some_and(|opener| line[index..].starts_with(opener)) {
317 break;
318 }
319 }
320 match (open, character) {
321 (_, '\\') => escaped = true,
322 (None, '"' | '\'' | '`') => open = Some(character),
323 (Some(quote), character) if character == quote => open = None,
324 _ => {}
325 }
326 }
327 out
328}
329
330fn find_unescaped(line: &str, fence: &str) -> Option<usize> {
333 let mut escaped = false;
334 for (index, character) in line.char_indices() {
335 if escaped {
336 escaped = false;
337 continue;
338 }
339 if character == '\\' {
340 escaped = true;
341 continue;
342 }
343 if line[index..].starts_with(fence) {
344 return Some(index);
345 }
346 }
347 None
348}
349
350fn live_from(file: &str, lines: &[&str]) -> Vec<Option<usize>> {
357 let fences: &[&str] = if is_python(file) {
358 &["\"\"\"", "'''"]
359 } else if comment_opener(file) == Some("//") && !is_rust(file) {
360 &["`"]
361 } else {
362 return vec![Some(0); lines.len()];
363 };
364 let comment = comment_opener(file);
365 let mut open: Option<&str> = None;
366 lines
367 .iter()
368 .map(|line| {
369 let Some(fence) = open else {
370 for opened in fence_toggles(line, fences, comment) {
371 open = match open {
372 None => Some(opened),
373 Some(current) if current == opened => None,
374 Some(current) => Some(current),
375 };
376 }
377 return Some(0);
378 };
379 let closes = find_unescaped(line, fence);
380 if closes.is_some() {
381 open = None;
382 }
383 closes.map(|index| index + fence.len())
384 })
385 .collect()
386}
387
388fn is_closing(line: &str) -> bool {
389 line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
390}
391
392const fn on_a_boundary(text: &str, index: usize) -> bool {
395 index == 0
396 || !text.as_bytes()[index - 1].is_ascii_alphanumeric()
397 && text.as_bytes()[index - 1] != b'-'
398 && text.as_bytes()[index - 1] != b'_'
399}
400
401fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
402 line.match_indices("KI-")
403 .filter(|(index, _)| on_a_boundary(line, *index))
404 .filter_map(|(index, _)| {
405 let rest = &line[index + 3..];
406 let slug: String = rest
407 .chars()
408 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
409 .collect();
410 let closes = rest[slug.len()..]
413 .chars()
414 .next()
415 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
416 (!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
417 })
418}
419
420fn permanent_reason(line: &str) -> Option<String> {
428 let after = line
429 .match_indices(MARKER)
430 .filter(|(index, _)| on_a_boundary(line, *index))
431 .map(|(index, _)| &line[index + MARKER.len()..])
432 .find(|after| after.is_empty() || after.starts_with(char::is_whitespace))?;
433 let rest = after
434 .trim_end()
435 .trim_end_matches("-->")
436 .trim_end_matches("*/")
437 .trim();
438 Some(rest.to_string())
439}
440
441fn raw_opener(text: &str, index: usize) -> Option<(usize, usize)> {
447 let after = text[index..].strip_prefix('r')?;
448 let hashes = after.chars().take_while(|c| *c == '#').count();
449 after[hashes..].starts_with('"').then_some((
450 1 + hashes + 1,
452 hashes,
453 ))
454}
455
456fn literal_end(text: &str, index: usize) -> Option<usize> {
463 if let Some((opener, hashes)) = raw_opener(text, index) {
464 let body = index + opener;
465 let close = format!("\"{}", "#".repeat(hashes));
466 return Some(
467 text[body..]
468 .find(&close)
469 .map_or(text.len(), |at| body + at + close.len()),
470 );
471 }
472 let quote = text[index..]
473 .chars()
474 .next()
475 .filter(|c| *c == '"' || *c == '\'')?;
476 let body = index + quote.len_utf8();
477 let mut escaped = false;
478 for (at, character) in text[body..].char_indices() {
479 if escaped {
480 escaped = false;
481 continue;
482 }
483 match character {
484 '\\' => escaped = true,
485 c if c == quote => return Some(body + at + c.len_utf8()),
486 _ => {}
487 }
488 }
489 Some(text.len())
490}
491
492fn from_code(digits: &str, base: u32) -> Option<char> {
494 u32::from_str_radix(digits, base)
495 .ok()
496 .and_then(char::from_u32)
497}
498
499fn code_digits(body: &mut std::str::Chars, first: Option<char>, width: usize, base: u32) -> String {
501 let mut digits: String = first.into_iter().collect();
502 while digits.len() < width {
503 match body.clone().next().filter(|c| c.is_digit(base)) {
504 Some(next) => {
505 body.next();
506 digits.push(next);
507 }
508 None => break,
509 }
510 }
511 digits
512}
513
514fn push_escaped(out: &mut String, body: &mut std::str::Chars) {
528 let Some(character) = body.next() else { return };
529 match character {
530 'n' => out.push('\n'),
531 'r' => out.push('\r'),
532 't' => out.push('\t'),
533 'f' => out.push('\u{0c}'),
534 'v' => out.push('\u{0b}'),
535 'a' => out.push('\u{07}'),
536 'b' => out.push('\u{08}'),
537 digit @ '0'..='7' => out.extend(from_code(&code_digits(body, Some(digit), 3, 8), 8)),
540 'x' => out.extend(from_code(&code_digits(body, None, 2, 16), 16)),
541 'N' if body.clone().next() == Some('{') => {
544 body.take_while(|c| *c != '}').for_each(drop);
545 }
546 'u' | 'U' => {
548 let digits = if body.clone().next() == Some('{') {
549 body.next();
550 body.take_while(|c| *c != '}').collect()
551 } else {
552 code_digits(body, None, if character == 'u' { 4 } else { 8 }, 16)
553 };
554 out.extend(from_code(&digits, 16));
555 }
556 other => out.push(other),
557 }
558}
559
560fn quoted_from(text: &str, from: usize) -> Option<String> {
567 let start = from + text[from..].len() - text[from..].trim_start().len();
568 let rest = &text[start..];
569 if let Some((opener, hashes)) = raw_opener(rest, 0) {
570 let close = format!("\"{}", "#".repeat(hashes));
571 return rest[opener..]
572 .find(&close)
573 .map(|at| rest[opener..opener + at].to_string());
574 }
575 let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?;
576 let mut body = rest[quote.len_utf8()..].chars();
577 let mut out = String::new();
578 while let Some(character) = body.next() {
579 match character {
580 '\\' => push_escaped(&mut out, &mut body),
581 c if c == quote => return Some(out),
582 c => out.push(c),
583 }
584 }
585 None
586}
587
588fn extent_from<'a>(code: &'a str, token: &str) -> Option<&'a str> {
596 let start = code.find(token)?;
597 let text = &code[start..];
598 let mut depth = 0i32;
599 let mut opened = false;
600 let mut index = 0usize;
601 while let Some(character) = text[index..].chars().next() {
602 if let Some(end) = literal_end(text, index) {
603 index = end;
604 continue;
605 }
606 match character {
607 '(' | '[' => {
608 depth += 1;
609 opened = true;
610 }
611 ')' | ']' => {
612 depth -= 1;
613 if opened && depth <= 0 {
614 return Some(&code[start..start + index + character.len_utf8()]);
615 }
616 }
617 _ => {}
618 }
619 index += character.len_utf8();
620 }
621 opened.then_some(&code[start..])
624}
625
626fn arguments(extent: &str) -> Vec<&str> {
628 let Some(open) = extent.find('(') else {
629 return Vec::new();
630 };
631 let mut out = Vec::new();
632 let mut depth = 0i32;
633 let mut from = open + 1;
634 let mut at = open + 1;
635 while let Some(character) = extent[at..].chars().next() {
636 if let Some(end) = literal_end(extent, at) {
637 at = end;
638 continue;
639 }
640 match character {
641 '(' | '[' | '{' => depth += 1,
642 ')' | ']' | '}' if depth > 0 => depth -= 1,
643 ')' => {
644 out.push(&extent[from..at]);
645 return out;
646 }
647 ',' if depth == 0 => {
648 out.push(&extent[from..at]);
649 from = at + 1;
650 }
651 _ => {}
652 }
653 at += character.len_utf8();
654 }
655 out
656}
657
658fn keyword_value(argument: &str) -> Option<String> {
664 let after = argument.trim_start().strip_prefix("reason")?;
665 let equals = after
666 .find('=')
667 .filter(|at| after[..*at].trim().is_empty())?;
668 if after[equals + 1..].starts_with('=') {
670 return None;
671 }
672 quoted_from(after, equals + 1)
673}
674
675fn reason_argument(extent: &str) -> Option<String> {
681 arguments(extent)
682 .iter()
683 .find_map(|argument| keyword_value(argument))
684}
685
686fn positional_reason(extent: &str, slot: usize) -> Option<String> {
689 reason_argument(extent).or_else(|| quoted_from(arguments(extent).get(slot)?, 0))
690}
691
692fn native_reason(file: &str, channel: Channel, token: &str, region: &[&str]) -> Option<String> {
701 fn trailing(text: &str) -> String {
702 text.trim_end()
703 .trim_end_matches("-->")
704 .trim_end_matches("*/")
705 .trim()
706 .to_string()
707 }
708 match channel {
709 Channel::None => None,
710 Channel::AfterBracket => {
711 let line = region.first()?;
712 let at = line.find(token)?;
713 let close = line[at..].find(']')?;
714 Some(trailing(&line[at + close + 1..]))
715 }
716 Channel::AfterSeparator => {
719 let line = region.first()?;
720 let at = line.find(token)?;
721 let separator = line[at..].find(" -- ")?;
722 Some(trailing(&line[at + separator + 4..]))
723 }
724 Channel::ReasonArgument
725 | Channel::ValueString
726 | Channel::FirstArgument
727 | Channel::SecondArgument => {
728 let code: Vec<&str> = region.iter().map(|line| code_region(file, line)).collect();
729 let joined = code.join("\n");
730 let extent = extent_from(&joined, token)?;
731 match channel {
732 Channel::ReasonArgument => reason_argument(extent),
733 Channel::ValueString => {
734 let equals = extent.find('=')?;
735 quoted_from(extent, equals + 1)
736 }
737 Channel::FirstArgument => positional_reason(extent, 0),
740 _ => positional_reason(extent, 1),
741 }
742 }
743 }
744}
745
746fn looks_binary(bytes: &[u8]) -> bool {
747 bytes.iter().take(4096).any(|&b| b == 0)
748}
749
750#[derive(Clone, Copy, PartialEq, Eq)]
756enum Disposition {
757 Missing,
760 KnownIssue,
762 Accepted,
765 Conflict,
767 MarkerWithoutReason,
769}
770
771struct Site {
773 file: String,
774 number: usize,
775 line: String,
776 annotation: Vec<String>,
777 region_from: usize,
780 form: &'static Form,
781}
782
783impl Site {
784 fn cites_a_case(&self) -> bool {
785 self.annotation
786 .iter()
787 .any(|line| cited_cases(line).next().is_some())
788 }
789
790 fn marker_reason(&self) -> Option<String> {
792 self.annotation
793 .iter()
794 .find_map(|line| permanent_reason(line))
795 }
796
797 fn native_reason(&self) -> Option<String> {
800 let region: Vec<&str> = self.annotation[self.region_from..]
801 .iter()
802 .map(String::as_str)
803 .collect();
804 native_reason(&self.file, self.form.channel, self.form.token, ®ion)
805 .filter(|reason| !reason.trim().is_empty())
806 }
807
808 fn disposition(&self) -> Disposition {
812 match (self.cites_a_case(), self.marker_reason()) {
813 (true, Some(_)) => Disposition::Conflict,
814 (true, None) => Disposition::KnownIssue,
815 (false, Some(reason)) if reason.is_empty() => Disposition::MarkerWithoutReason,
816 (false, Some(_)) => Disposition::Accepted,
817 (false, None) if self.native_reason().is_some() => Disposition::Accepted,
818 (false, None) => Disposition::Missing,
819 }
820 }
821}
822
823fn unbalanced(text: &str) -> i32 {
824 let mut depth = 0i32;
825 let mut open: Option<u8> = None;
826 let mut escaped = false;
827 for byte in text.bytes() {
828 if escaped {
829 escaped = false;
830 continue;
831 }
832 match (open, byte) {
833 (_, b'\\') => escaped = true,
834 (None, b'"' | b'\'') => open = Some(byte),
835 (Some(quote), byte) if byte == quote => open = None,
836 (None, b'(' | b'[') => depth += 1,
837 (None, b')' | b']') => depth -= 1,
838 _ => {}
839 }
840 }
841 depth
842}
843
844fn annotation(file: &str, lines: &[&str], index: usize, start: usize) -> (Vec<String>, usize) {
856 let mut out = Vec::new();
857 if let Some(opener) = comment_opener(file) {
858 if let Some(above) = index
859 .checked_sub(1)
860 .map(|previous| lines[previous].trim_start())
861 .filter(|previous| previous.starts_with(opener))
862 {
863 out.push(above.to_string());
864 }
865 }
866 let region_from = out.len();
867 out.push(lines[index][start..].to_string());
868 let mut depth = unbalanced(code_region(file, lines[index]));
872 let mut continuation = Vec::new();
873 let mut next = index + 1;
874 while depth > 0 && next < lines.len() {
875 continuation.push(lines[next].to_string());
876 depth += unbalanced(code_region(file, lines[next]));
877 next += 1;
878 }
879 if depth == 0 {
880 out.extend(continuation);
881 }
882 (out, region_from)
883}
884
885fn sites(ctx: &GateCtx) -> Result<Vec<Site>, GateError> {
886 let mut sites = Vec::new();
887 for file in walk_files(ctx) {
888 if file
889 .components()
890 .any(|part| part.as_str() == "known-issues")
891 {
892 continue;
893 }
894 let bytes =
895 std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
896 if looks_binary(&bytes) {
897 continue;
898 }
899 let Ok(text) = String::from_utf8(bytes) else {
900 continue;
901 };
902 let name = file.as_str().trim_start_matches("./").to_string();
903 let lines: Vec<&str> = text.lines().collect();
904 #[allow(clippy::case_sensitive_file_extension_comparisons)]
910 let kinds = if name.ends_with(".md") {
911 classify(&text)
912 } else {
913 Vec::new()
914 };
915 let live = live_from(&name, &lines);
916 for (index, line) in lines.iter().enumerate() {
917 if matches!(
918 kinds.get(index),
919 Some(LineKind::Fence | LineKind::FrontMatter)
920 ) {
921 continue;
922 }
923 let Some(offset) = live[index] else { continue };
924 let Some((found, form)) = suppression_at(&name, &line[offset..]) else {
925 continue;
926 };
927 let start = offset + found;
928 if !is_closing(line) {
929 let (annotation, region_from) = annotation(&name, &lines, index, start);
930 sites.push(Site {
931 file: name.clone(),
932 number: index + 1,
933 line: (*line).to_string(),
934 annotation,
935 region_from,
936 form,
937 });
938 }
939 }
940 }
941 Ok(sites)
942}
943
944pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
950 let sites = sites(ctx)?;
951 let mut violations = Vec::new();
952
953 let mut caseless = Vec::new();
954 for site in &sites {
955 match site.disposition() {
956 Disposition::KnownIssue | Disposition::Accepted => {}
957 Disposition::MarkerWithoutReason => {
958 violations.push(Violation::Finding(Finding::on_line(
959 PERMANENT,
960 &site.file,
961 site.number,
962 "the permanent marker states no reason",
963 )));
964 }
965 Disposition::Conflict => violations.push(Violation::Finding(Finding::on_line(
966 PERMANENT,
967 &site.file,
968 site.number,
969 "names a case and states a permanent exception",
970 ))),
971 Disposition::Missing => caseless.push(site),
972 }
973 }
974 if !caseless.is_empty() {
975 violations.push(Violation::Finding(Finding::global(CASE, "")));
976 for site in caseless {
977 violations.push(Violation::Note(format!(
978 "./{}:{}:{}",
979 site.file, site.number, site.line
980 )));
981 }
982 }
983
984 let known: BTreeSet<String> = ki_records(ctx, args)?
985 .iter()
986 .filter_map(|record| {
987 record
988 .file_name()
989 .map(|name| name.trim_end_matches(".md").to_string())
990 })
991 .collect();
992 let cited: BTreeSet<String> = sites
993 .iter()
994 .flat_map(|site| site.annotation.iter().flat_map(|line| cited_cases(line)))
995 .collect();
996 for case in cited {
997 if !known.contains(&case) {
998 violations.push(Violation::Finding(Finding::global(
999 CASE,
1000 format!("{case} resolves to no record"),
1001 )));
1002 }
1003 }
1004 Ok(violations)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 fn fixture() -> tempfile::TempDir {
1012 let dir = tempfile::tempdir().unwrap();
1013 let records = dir.path().join("_docs/reference/known-issues");
1014 std::fs::create_dir_all(&records).unwrap();
1015 std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
1016 dir
1017 }
1018
1019 fn run_on(name: &str, text: &str) -> Vec<String> {
1020 let dir = fixture();
1021 std::fs::write(dir.path().join(name), text).unwrap();
1022 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1023 run(&ctx, &[])
1024 .unwrap()
1025 .iter()
1026 .map(ToString::to_string)
1027 .collect()
1028 }
1029
1030 #[test]
1031 fn a_repository_without_suppressions_passes() {
1032 let dir = fixture();
1033 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1034 assert!(run(&ctx, &[]).unwrap().is_empty());
1035 }
1036
1037 #[test]
1038 fn every_form_passes_when_it_names_a_record() {
1039 for (name, text) in [
1040 (
1041 "local.md",
1042 "<!-- markdownlint-disable MD013 KI-vendor-quirk -->\n",
1043 ),
1044 ("local.md", "<!-- dprint-ignore KI-vendor-quirk -->\n"),
1045 ("local.rs", "#[allow(dead_code)] // KI-vendor-quirk\n"),
1046 ("local.rs", "#[expect(dead_code)] // KI-vendor-quirk\n"),
1047 ("local.rs", "#[ignore = \"KI-vendor-quirk\"]\n"),
1048 (
1049 "local.sh",
1050 "# shellcheck disable=SC2329 # KI-vendor-quirk\n",
1051 ),
1052 ("local.py", "x = 1 # noqa: E501 KI-vendor-quirk\n"),
1053 ("local.py", "x = 1 # type: ignore KI-vendor-quirk\n"),
1054 (
1055 "local.yml",
1056 "on: push # zizmor: ignore[dangerous-triggers] KI-vendor-quirk\n",
1057 ),
1058 (
1059 "local.ts",
1060 "// eslint-disable-next-line no-eval KI-vendor-quirk\n",
1061 ),
1062 ] {
1063 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1064 }
1065 }
1066
1067 #[test]
1068 fn every_form_fails_when_it_says_nothing() {
1069 for (name, text) in [
1070 ("local.md", "<!-- markdownlint-disable MD013 -->\n"),
1071 ("local.rs", "#[allow(dead_code)]\n"),
1072 ("local.rs", "#![allow(clippy::unwrap_used)]\n"),
1073 ("local.sh", "# shellcheck disable=SC2329\n"),
1074 ("local.py", "x = 1 # noqa: E501\n"),
1075 ("local.ts", "// eslint-disable-next-line no-eval\n"),
1076 ] {
1077 let out = run_on(name, text);
1078 assert_eq!(out.len(), 2, "{name}: {text}");
1079 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1080 assert!(out[1].contains(&format!("{name}:1")));
1081 }
1082 }
1083
1084 #[test]
1085 fn a_permanent_marker_with_a_reason_passes() {
1086 for (name, text) in [
1087 (
1088 "local.rs",
1089 "// sdd: permanent the braces are a template placeholder\n#[allow(clippy::x)]\n",
1090 ),
1091 (
1092 "local.rs",
1093 "#[allow(clippy::x)] // sdd: permanent the lint is wrong here\n",
1094 ),
1095 (
1096 "local.sh",
1097 "# shellcheck disable=SC2329 # sdd: permanent reached through a trap\n",
1098 ),
1099 (
1100 "local.md",
1101 "<!-- markdownlint-disable MD013 sdd: permanent the table is data -->\n",
1102 ),
1103 ] {
1104 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1105 }
1106 }
1107
1108 #[test]
1109 fn a_tools_own_reason_states_a_permanent_exception() {
1110 for (name, text) in [
1111 (
1112 "local.yml",
1113 "on: push # zizmor: ignore[dangerous-triggers] the definition is the trusted one\n",
1114 ),
1115 (
1116 "local.rs",
1117 "#[allow(dead_code, reason = \"the field is the wire format\")]\n",
1118 ),
1119 (
1120 "local.rs",
1121 "#[expect(dead_code, reason = \"the field is the wire format\")]\n",
1122 ),
1123 (
1124 "local.rs",
1125 "#[ignore = \"the fixture needs a live network\"]\n",
1126 ),
1127 (
1128 "local.py",
1129 "@pytest.mark.xfail(reason=\"the parser rejects a valid literal\", strict=True)\ndef test_x():\n pass\n",
1130 ),
1131 (
1132 "local.ts",
1133 "// eslint-disable-next-line no-eval -- the input is a literal in this file\n",
1134 ),
1135 ] {
1136 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1137 }
1138 }
1139
1140 #[test]
1143 fn a_generated_workflow_states_its_reason_in_its_own_idiom() {
1144 let text = concat!(
1145 "on:\n",
1146 " # zizmor: ignore[dangerous-triggers] the trigger is what makes this gate\n",
1147 " # unforgeable, and the header above states why it is safe here.\n",
1148 " pull_request_target:\n",
1149 );
1150 assert!(run_on("local.yml", text).is_empty());
1151 }
1152
1153 #[test]
1154 fn a_tools_own_reason_left_empty_is_no_reason() {
1155 for (name, text) in [
1156 (
1157 "local.yml",
1158 "on: push # zizmor: ignore[dangerous-triggers]\n",
1159 ),
1160 ("local.rs", "#[allow(dead_code, reason = \"\")]\n"),
1161 ("local.rs", "#[ignore]\n"),
1162 ("local.ts", "// eslint-disable-next-line no-eval --\n"),
1163 ] {
1164 let out = run_on(name, text);
1165 assert_eq!(out.len(), 2, "{name}: {text}");
1166 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1167 }
1168 }
1169
1170 #[test]
1171 fn a_tool_that_defines_no_reason_position_still_takes_the_marker() {
1172 for (name, text) in [
1173 ("local.py", "x = 1 # noqa: E501 the line is one URL\n"),
1174 (
1175 "local.sh",
1176 "# shellcheck disable=SC2329 reached through a trap\n",
1177 ),
1178 (
1179 "local.md",
1180 "<!-- markdownlint-disable MD013 the table is data -->\n",
1181 ),
1182 ] {
1183 let out = run_on(name, text);
1184 assert_eq!(out.len(), 2, "{name}: {text}");
1185 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1186 }
1187 }
1188
1189 #[test]
1190 fn prose_above_a_suppression_is_not_the_tools_own_reason() {
1191 for (name, text) in [
1192 (
1193 "local.rs",
1194 "// reason = \"this comment is not the attribute\"\n#[allow(dead_code)]\n",
1195 ),
1196 (
1197 "local.rs",
1198 "#[allow(dead_code)] // reason = \"this comment is not the attribute\"\n",
1199 ),
1200 ] {
1201 let out = run_on(name, text);
1202 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1203 }
1204 }
1205
1206 #[test]
1209 fn a_reason_beside_a_suppression_is_not_the_suppressions() {
1210 for (name, text) in [
1211 (
1212 "local.rs",
1213 "#[allow(dead_code)] #[doc = \"reason = 'unrelated prose'\"] fn f() {}\n",
1214 ),
1215 (
1216 "local.rs",
1217 "#[allow(dead_code)] #[expect(unused, reason = \"the other one states it\")]\n",
1218 ),
1219 ] {
1220 let out = run_on(name, text);
1221 assert_eq!(
1222 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1223 "{name}: {text}"
1224 );
1225 }
1226 }
1227
1228 #[test]
1229 fn a_whitespace_reason_states_nothing() {
1230 for (name, text) in [
1231 ("local.rs", "#[allow(dead_code, reason = \" \")]\n"),
1232 ("local.rs", "#[ignore = \" \"]\n"),
1233 (
1234 "local.py",
1235 "@pytest.mark.skip(reason=\" \")\ndef test_x():\n pass\n",
1236 ),
1237 (
1238 "local.py",
1239 "@unittest.skip(\" \")\ndef test_x():\n pass\n",
1240 ),
1241 ] {
1242 let out = run_on(name, text);
1243 assert_eq!(
1244 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1245 "{name}: {text}"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn a_raw_string_reason_is_a_reason() {
1252 for text in [
1253 "#[ignore = r\"requires a live service\"]\n",
1254 "#[ignore = r#\"requires a \"live\" service\"#]\n",
1255 "#[allow(dead_code, reason = r\"the field is the wire format\")]\n",
1256 ] {
1257 assert!(run_on("local.rs", text).is_empty(), "{text}");
1258 }
1259 }
1260
1261 #[test]
1262 fn a_positional_skip_reason_is_a_reason() {
1263 for text in [
1264 "@unittest.skip(\"the service is unavailable\")\ndef test_x():\n pass\n",
1265 "@unittest.skipIf(sys.platform == \"win32\", \"the path is posix only\")\ndef test_x():\n pass\n",
1266 "@unittest.skipUnless(os.name == \"posix\", \"the path is posix only\")\ndef test_x():\n pass\n",
1267 ] {
1268 assert!(run_on("local.py", text).is_empty(), "{text}");
1269 }
1270 }
1271
1272 #[test]
1275 fn a_skip_condition_is_not_its_reason() {
1276 let out = run_on(
1277 "local.py",
1278 "@unittest.skipIf(sys.platform == \"win32\")\ndef test_x():\n pass\n",
1279 );
1280 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1281 }
1282
1283 #[test]
1286 fn a_keyword_skip_reason_is_a_reason() {
1287 for text in [
1288 "@unittest.skip(reason=\"the service is unavailable\")\ndef test_x():\n pass\n",
1289 "@unittest.skipIf(condition=True, reason=\"the path is posix only\")\ndef test_x():\n pass\n",
1290 "@unittest.skipUnless(reason=\"the path is posix only\", condition=True)\ndef test_x():\n pass\n",
1291 ] {
1292 assert!(run_on("local.py", text).is_empty(), "{text}");
1293 }
1294 }
1295
1296 #[test]
1299 fn an_escaped_whitespace_reason_states_nothing() {
1300 for text in [
1301 "#[allow(dead_code, reason = \"\\t\")]\n",
1302 "#[allow(dead_code, reason = \"\\n\\r\")]\n",
1303 ] {
1304 let out = run_on("local.rs", text);
1305 assert_eq!(
1306 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1307 "{text}"
1308 );
1309 }
1310 }
1311
1312 #[test]
1315 fn a_raw_reason_carrying_a_delimiter_is_still_one_literal() {
1316 let text = "#[allow(dead_code, reason = r#\"the token \")]\" is data\"#)]\n";
1317 assert!(run_on("local.rs", text).is_empty());
1318 }
1319
1320 #[test]
1322 fn a_nested_call_does_not_lend_its_reason() {
1323 for (name, text) in [
1324 (
1325 "local.py",
1326 "@unittest.skipIf(condition=check(reason=\"borrowed\"), reason=\"\")\ndef test_x():\n pass\n",
1327 ),
1328 (
1329 "local.py",
1330 "@pytest.mark.skip(reason=compute(reason=\"borrowed\"))\ndef test_x():\n pass\n",
1331 ),
1332 ] {
1333 let out = run_on(name, text);
1334 assert_eq!(
1335 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1336 "{name}: {text}"
1337 );
1338 }
1339 }
1340
1341 #[test]
1343 fn a_numeric_whitespace_escape_states_nothing() {
1344 for text in [
1345 "#[allow(dead_code, reason = \"\\x20\")]\n",
1346 "#[allow(dead_code, reason = \"\\u{20}\")]\n",
1347 "#[allow(dead_code, reason = \"\\u{20}\\t\\x20\")]\n",
1348 ] {
1349 let out = run_on("local.rs", text);
1350 assert_eq!(
1351 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1352 "{text}"
1353 );
1354 }
1355 }
1356
1357 #[test]
1360 fn every_whitespace_escape_states_nothing() {
1361 for (name, text) in [
1362 (
1363 "local.py",
1364 "@unittest.skip(\"\\v\")\ndef test_x():\n pass\n",
1365 ),
1366 (
1367 "local.py",
1368 "@unittest.skip(\"\\f\")\ndef test_x():\n pass\n",
1369 ),
1370 (
1371 "local.py",
1372 "@unittest.skip(\"\\040\")\ndef test_x():\n pass\n",
1373 ),
1374 (
1375 "local.py",
1376 "@unittest.skip(\"\\u0020\")\ndef test_x():\n pass\n",
1377 ),
1378 (
1379 "local.py",
1380 "@unittest.skip(\"\\N{SPACE}\")\ndef test_x():\n pass\n",
1381 ),
1382 ] {
1383 let out = run_on(name, text);
1384 assert_eq!(
1385 out[0], "FAIL spec-to-code:a-suppression-names-its-case",
1386 "{name}: {text}"
1387 );
1388 }
1389 }
1390
1391 #[test]
1394 fn a_numeric_escape_inside_a_reason_keeps_it() {
1395 let text = "#[allow(dead_code, reason = \"the\\x20field is the wire format\")]\n";
1396 assert!(run_on("local.rs", text).is_empty());
1397 let named =
1398 "@unittest.skip(\"\\N{BULLET} the service is unavailable\")\ndef test_x():\n pass\n";
1399 assert!(run_on("local.py", named).is_empty());
1400 }
1401
1402 #[test]
1404 fn a_longer_name_is_not_the_reason_parameter() {
1405 let out = run_on(
1406 "local.py",
1407 "@unittest.skipIf(reason_code == \"x\", 12)\ndef test_x():\n pass\n",
1408 );
1409 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1410 }
1411
1412 #[test]
1413 fn a_case_inside_a_tools_own_reason_stays_a_known_issue() {
1414 assert!(
1415 run_on(
1416 "local.rs",
1417 "#[allow(dead_code, reason = \"KI-vendor-quirk\")]\n"
1418 )
1419 .is_empty()
1420 );
1421 let out = run_on("local.rs", "#[allow(dead_code, reason = \"KI-absent\")]\n");
1422 assert_eq!(
1423 out[0],
1424 "FAIL spec-to-code:a-suppression-names-its-case: KI-absent resolves to no record"
1425 );
1426 }
1427
1428 #[test]
1429 fn a_marker_on_the_line_above_a_multi_line_attribute_passes() {
1430 let text = "// sdd: permanent a test module panics as its failure signal\n#![allow(\n clippy::unwrap_used\n)]\n";
1431 assert!(run_on("local.rs", text).is_empty());
1432 }
1433
1434 #[test]
1435 fn a_non_comment_line_above_supplies_nothing() {
1436 let text = "let reason = \"KI-vendor-quirk\";\n#[allow(dead_code)]\n";
1437 let out = run_on("local.rs", text);
1438 assert_eq!(out.len(), 2);
1439 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1440 }
1441
1442 #[test]
1443 fn a_marker_without_a_reason_is_rejected() {
1444 for text in [
1445 "#[allow(dead_code)] // sdd: permanent\n",
1446 "// sdd: permanent\n#[allow(dead_code)]\n",
1447 ] {
1448 let out = run_on("local.rs", text);
1449 assert_eq!(out.len(), 1, "{text}");
1450 assert!(
1451 out[0].ends_with(": the permanent marker states no reason"),
1452 "{text}"
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn an_expected_failure_is_a_suppression() {
1459 assert!(
1460 run_on(
1461 "test_x.py",
1462 "@pytest.mark.xfail(reason=\"KI-vendor-quirk\", strict=True)\ndef test_x():\n pass\n"
1463 )
1464 .is_empty()
1465 );
1466 let out = run_on(
1467 "test_x.py",
1468 "@pytest.mark.xfail(strict=True)\ndef test_x():\n pass\n",
1469 );
1470 assert_eq!(out.len(), 2);
1471 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1472 }
1473
1474 #[test]
1475 fn a_form_inside_a_string_is_a_quotation() {
1476 for (name, text) in [
1477 ("local.py", "value = \"# noqa: E501\"\n"),
1478 ("local.sh", "printf '%s' '# shellcheck disable=SC2329'\n"),
1479 (
1480 "local.ts",
1481 "const form = \"// eslint-disable-next-line\";\n",
1482 ),
1483 ] {
1484 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1485 }
1486 }
1487
1488 #[test]
1489 fn a_fenced_example_in_a_document_is_a_quotation() {
1490 for fence in ["```markdown", "~~~markdown", "````markdown"] {
1491 let close = fence.trim_end_matches("markdown");
1492 let text = format!(
1493 "# Chapter\n\n{fence}\n<!-- markdownlint-disable MD013 -->\n{close}\n\nProse.\n"
1494 );
1495 assert!(run_on("chapter.md", &text).is_empty(), "{fence}");
1496 }
1497 }
1498
1499 #[test]
1500 fn an_apostrophe_before_a_live_directive_does_not_hide_it() {
1501 let out = run_on("local.py", "value = \"it's long\" # noqa: E501\n");
1502 assert_eq!(out.len(), 2);
1503 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1504 }
1505
1506 #[test]
1507 fn a_marker_inside_a_longer_word_is_not_the_marker() {
1508 let out = run_on(
1509 "local.py",
1510 "x = 1 # noqa: E501 not-sdd: permanent reason\n",
1511 );
1512 assert_eq!(out.len(), 2);
1513 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1514 }
1515
1516 #[test]
1517 fn a_case_written_in_code_before_the_comment_is_not_the_suppressions() {
1518 let out = run_on("local.py", "path = \"KI-vendor-quirk.md\" # noqa: E501\n");
1519 assert_eq!(out.len(), 2);
1520 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1521 }
1522
1523 #[test]
1524 fn a_multiline_expected_failure_carries_its_case() {
1525 let text = "@pytest.mark.xfail(\n reason=\"KI-vendor-quirk\",\n strict=True,\n)\ndef test_x():\n pass\n";
1526 assert!(run_on("test_x.py", text).is_empty());
1527 }
1528
1529 #[test]
1530 fn a_long_attribute_carries_its_case_past_any_line_count() {
1531 let lints = " clippy::a_lint,\n".repeat(20);
1532 let text = format!("#[allow(\n{lints} // KI-vendor-quirk\n)]\nfn f() {{}}\n");
1533 assert!(run_on("local.rs", &text).is_empty());
1534 }
1535
1536 #[test]
1537 fn an_apostrophe_in_comment_prose_does_not_hide_a_later_directive() {
1538 let out = run_on("local.py", "value = 1 # don't reflow # noqa: E501\n");
1539 assert_eq!(out.len(), 2);
1540 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1541 }
1542
1543 #[test]
1544 fn comment_punctuation_does_not_extend_the_annotation() {
1545 let text = "#[allow(dead_code)] // (\nfn kept() {} // KI-vendor-quirk\n";
1546 let out = run_on("local.rs", text);
1547 assert_eq!(out.len(), 2);
1548 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1549 }
1550
1551 #[test]
1552 fn a_line_carrying_multibyte_text_is_scanned_without_panicking() {
1553 assert!(run_on("local.py", "value = \"a 🤖 walks in\" # a note\n").is_empty());
1554 let out = run_on("local.py", "value = \"a 🤖 walks in\" # noqa: E501\n");
1555 assert_eq!(out.len(), 2);
1556 }
1557
1558 #[test]
1559 fn a_form_inside_a_multiline_string_is_a_quotation() {
1560 for (name, text) in [
1561 ("local.py", "DOC = \"\"\"\n# noqa: E501\n\"\"\"\n"),
1562 (
1563 "local.ts",
1564 "const doc = `\n// eslint-disable-next-line\n`;\n",
1565 ),
1566 ("local.ts", "const doc = `// eslint-disable-next-line`;\n"),
1567 ] {
1568 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1569 }
1570 }
1571
1572 #[test]
1573 fn a_linters_other_spellings_are_the_same_form() {
1574 for text in [
1575 "value = 1 # NOQA: E501\n",
1576 "# ruff: noqa\n",
1577 "# flake8: noqa\n",
1578 ] {
1579 let out = run_on("local.py", text);
1580 assert_eq!(out.len(), 2, "{text}");
1581 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1582 }
1583 }
1584
1585 #[test]
1586 fn the_line_a_multiline_string_closes_on_is_still_content() {
1587 for (name, text) in [
1588 ("local.py", "DOC = \"\"\"\n# noqa: E501 \"\"\"\n"),
1589 ("local.ts", "const doc = `\n// eslint-disable-next-line`;\n"),
1590 ] {
1591 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1592 }
1593 }
1594
1595 #[test]
1596 fn an_escaped_delimiter_closes_no_multiline_string() {
1597 let text = "const t = `\nconst label = \\`value\\`;\n// eslint-disable-next-line\n`;\n";
1598 assert!(run_on("local.ts", text).is_empty());
1599 }
1600
1601 #[test]
1602 fn a_suppression_after_a_closing_delimiter_is_live() {
1603 let out = run_on(
1604 "local.py",
1605 "DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501\n",
1606 );
1607 assert_eq!(out.len(), 2);
1608 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1609 assert!(out[1].contains("local.py:3"));
1610 let text = "DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501 KI-vendor-quirk\n";
1611 assert!(run_on("local.py", text).is_empty());
1612 }
1613
1614 #[test]
1615 fn a_quoted_fence_delimiter_opens_no_multiline_string() {
1616 for opener in [
1617 "delimiter = '\"\"\"'\n",
1618 "# a docstring opens with \"\"\"\n",
1619 ] {
1620 let out = run_on("local.py", &format!("{opener}value = 1 # noqa: E501\n"));
1621 assert_eq!(out.len(), 2, "{opener}");
1622 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1623 }
1624 }
1625
1626 #[test]
1627 fn a_token_that_runs_on_is_not_the_token() {
1628 let out = run_on(
1629 "local.py",
1630 "x = 1 # noqa: E501 sdd: permanently justified\n",
1631 );
1632 assert_eq!(out.len(), 2);
1633 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1634
1635 let out = run_on("local.rs", "#[allow(dead_code)] // KI-vendor-quirkXYZ\n");
1636 assert_eq!(out.len(), 2);
1637 assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
1638 }
1639
1640 #[test]
1641 fn a_case_and_a_marker_together_are_rejected() {
1642 let out = run_on(
1643 "local.rs",
1644 "#[allow(dead_code)] // KI-vendor-quirk sdd: permanent both\n",
1645 );
1646 assert_eq!(out.len(), 1);
1647 assert!(out[0].ends_with(": names a case and states a permanent exception"));
1648 }
1649
1650 #[test]
1651 fn a_case_resolving_to_no_record_is_rejected() {
1652 let out = run_on(
1653 "local.md",
1654 "<!-- markdownlint-disable KI-absent-record -->\n",
1655 );
1656 assert_eq!(
1657 out,
1658 vec![
1659 "FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
1660 .to_string()
1661 ]
1662 );
1663 }
1664
1665 #[test]
1666 fn a_form_named_outside_its_file_kind_is_a_quotation() {
1667 for (name, text) in [
1668 (
1669 "prose.md",
1670 "The `#[allow(dead_code)]` attribute suppresses a lint.\n",
1671 ),
1672 (
1673 "prose.md",
1674 "A Python file carries `# noqa: E501` at the line.\n",
1675 ),
1676 (
1677 "local.rs",
1678 "let form = \"<!-- markdownlint-disable -->\";\n",
1679 ),
1680 ("local.rs", "let form = \"# shellcheck disable=SC2329\";\n"),
1681 ("local.rs", "let form = \"// eslint-disable-next-line\";\n"),
1682 ] {
1683 assert!(run_on(name, text).is_empty(), "{name}: {text}");
1684 }
1685 }
1686
1687 #[test]
1688 fn closing_markers_are_not_suppressions() {
1689 let text = "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n";
1690 assert!(run_on("local.md", text).is_empty());
1691 }
1692
1693 #[test]
1694 fn a_vendored_tree_is_skipped() {
1695 let dir = fixture();
1696 let vendored = dir.path().join("third-party/upstream");
1697 std::fs::create_dir_all(&vendored).unwrap();
1698 std::fs::write(vendored.join("hook.py"), "x = 1 # noqa: E501\n").unwrap();
1699 let ctx = GateCtx::new(dir.path().to_str().unwrap());
1700 assert!(run(&ctx, &[]).unwrap().is_empty());
1701 }
1702}