use std::collections::BTreeSet;
use crate::domain::finding::Finding;
use crate::domain::rule_id::RuleId;
use crate::gates::markdown_prose::{LineKind, classify};
use crate::gates::paths::ki_records;
use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
pub const CITES: &[RuleId] = &[
RuleId::SuppressionNamesItsCase,
RuleId::PermanentExceptionStatesItsReason,
];
const CASE: RuleId = RuleId::SuppressionNamesItsCase;
const PERMANENT: RuleId = RuleId::PermanentExceptionStatesItsReason;
const MARKER: &str = "sdd: permanent";
#[derive(Clone, Copy, PartialEq, Eq)]
enum Opener {
Line,
After(&'static str),
}
struct Family {
suffixes: &'static [&'static str],
opener: Opener,
tokens: &'static [&'static str],
}
const FAMILIES: &[Family] = &[
Family {
suffixes: &[".rs"],
opener: Opener::Line,
tokens: &[
"#[allow(",
"#[expect(",
"#![allow(",
"#![expect(",
"#[ignore",
],
},
Family {
suffixes: &[".py"],
opener: Opener::Line,
tokens: &[
"@pytest.mark.xfail",
"@pytest.mark.skip",
"@unittest.skip",
"@unittest.expectedFailure",
],
},
Family {
suffixes: &[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"],
opener: Opener::After("#"),
tokens: &[
"shellcheck disable=",
"noqa",
"ruff: noqa",
"flake8: noqa",
"type: ignore",
"zizmor: ignore[",
],
},
Family {
suffixes: &[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
opener: Opener::After("//"),
tokens: &["eslint-disable"],
},
Family {
suffixes: &[".md", ".html"],
opener: Opener::After("<!--"),
tokens: &["dprint-ignore", "markdownlint-disable"],
},
];
fn comment_opener(file: &str) -> Option<&'static str> {
const OPENERS: &[(&[&str], &str)] = &[
(&[".rs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], "//"),
(&[".py", ".sh", ".bash", ".yaml", ".yml", ".toml"], "#"),
];
OPENERS
.iter()
.find(|(suffixes, _)| suffixes.iter().any(|suffix| file.ends_with(suffix)))
.map(|(_, opener)| *opener)
}
fn comment_start(line: &str, opener: &str, quotes: &[char]) -> Option<usize> {
let mut open: Option<char> = None;
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
if open.is_none() && line[index..].starts_with(opener) {
return Some(index);
}
match (open, character) {
(_, '\\') => escaped = true,
(None, character) if quotes.contains(&character) => open = Some(character),
(Some(quote), character) if character == quote => open = None,
_ => {}
}
}
None
}
fn quote_marks(file: &str) -> &'static [char] {
if comment_opener(file) == Some("//") && !is_rust(file) {
&['"', '\'', '`']
} else {
&['"', '\'']
}
}
fn is_rust(file: &str) -> bool {
#[allow(clippy::case_sensitive_file_extension_comparisons)]
file.ends_with(".rs")
}
fn is_python(file: &str) -> bool {
#[allow(clippy::case_sensitive_file_extension_comparisons)]
file.ends_with(".py")
}
fn code_region<'a>(file: &str, line: &'a str) -> &'a str {
comment_opener(file)
.and_then(|opener| comment_start(line, opener, quote_marks(file)))
.map_or(line, |index| &line[..index])
}
fn comment_carries(comment: &str, opener: &str, token: &str) -> bool {
let lowered = comment.to_ascii_lowercase();
let mut start = 0usize;
while let Some(offset) = lowered[start..].find(opener) {
let index = start + offset;
if lowered[index + opener.len()..]
.trim_start_matches(' ')
.starts_with(token)
{
return true;
}
start = index + opener.len();
}
false
}
fn suppression_at(file: &str, line: &str) -> Option<usize> {
for family in FAMILIES {
if !family.suffixes.iter().any(|suffix| file.ends_with(suffix)) {
continue;
}
match family.opener {
Opener::Line => {
let code = line.trim_start();
if family.tokens.iter().any(|token| code.starts_with(token)) {
return Some(0);
}
}
Opener::After(opener) => {
let Some(index) = comment_start(line, opener, quote_marks(file)) else {
continue;
};
let comment = &line[index..];
if family
.tokens
.iter()
.any(|token| comment_carries(comment, opener, token))
{
return Some(index);
}
}
}
}
None
}
fn fence_toggles<'a>(line: &'a str, fences: &[&'a str], comment: Option<&str>) -> Vec<&'a str> {
let mut out = Vec::new();
let mut open: Option<char> = None;
let mut escaped = false;
let mut skip_to = 0usize;
for (index, character) in line.char_indices() {
if index < skip_to {
continue;
}
if escaped {
escaped = false;
continue;
}
if open.is_none() {
if let Some(fence) = fences
.iter()
.find(|fence| line[index..].starts_with(**fence))
{
out.push(*fence);
skip_to = index + fence.len();
continue;
}
if comment.is_some_and(|opener| line[index..].starts_with(opener)) {
break;
}
}
match (open, character) {
(_, '\\') => escaped = true,
(None, '"' | '\'' | '`') => open = Some(character),
(Some(quote), character) if character == quote => open = None,
_ => {}
}
}
out
}
fn find_unescaped(line: &str, fence: &str) -> Option<usize> {
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if line[index..].starts_with(fence) {
return Some(index);
}
}
None
}
fn live_from(file: &str, lines: &[&str]) -> Vec<Option<usize>> {
let fences: &[&str] = if is_python(file) {
&["\"\"\"", "'''"]
} else if comment_opener(file) == Some("//") && !is_rust(file) {
&["`"]
} else {
return vec![Some(0); lines.len()];
};
let comment = comment_opener(file);
let mut open: Option<&str> = None;
lines
.iter()
.map(|line| {
let Some(fence) = open else {
for opened in fence_toggles(line, fences, comment) {
open = match open {
None => Some(opened),
Some(current) if current == opened => None,
Some(current) => Some(current),
};
}
return Some(0);
};
let closes = find_unescaped(line, fence);
if closes.is_some() {
open = None;
}
closes.map(|index| index + fence.len())
})
.collect()
}
fn is_closing(line: &str) -> bool {
line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
}
const fn on_a_boundary(text: &str, index: usize) -> bool {
index == 0
|| !text.as_bytes()[index - 1].is_ascii_alphanumeric()
&& text.as_bytes()[index - 1] != b'-'
&& text.as_bytes()[index - 1] != b'_'
}
fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
line.match_indices("KI-")
.filter(|(index, _)| on_a_boundary(line, *index))
.filter_map(|(index, _)| {
let rest = &line[index + 3..];
let slug: String = rest
.chars()
.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
.collect();
let closes = rest[slug.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
(!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
})
}
fn permanent_reason(line: &str) -> Option<String> {
let after = line
.match_indices(MARKER)
.filter(|(index, _)| on_a_boundary(line, *index))
.map(|(index, _)| &line[index + MARKER.len()..])
.find(|after| after.is_empty() || after.starts_with(char::is_whitespace))?;
let rest = after
.trim_end()
.trim_end_matches("-->")
.trim_end_matches("*/")
.trim();
Some(rest.to_string())
}
fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(4096).any(|&b| b == 0)
}
struct Site {
file: String,
number: usize,
line: String,
annotation: Vec<String>,
}
impl Site {
fn cites_a_case(&self) -> bool {
self.annotation
.iter()
.any(|line| cited_cases(line).next().is_some())
}
fn reason(&self) -> Option<String> {
self.annotation
.iter()
.find_map(|line| permanent_reason(line))
}
}
fn unbalanced(text: &str) -> i32 {
let mut depth = 0i32;
let mut open: Option<u8> = None;
let mut escaped = false;
for byte in text.bytes() {
if escaped {
escaped = false;
continue;
}
match (open, byte) {
(_, b'\\') => escaped = true,
(None, b'"' | b'\'') => open = Some(byte),
(Some(quote), byte) if byte == quote => open = None,
(None, b'(' | b'[') => depth += 1,
(None, b')' | b']') => depth -= 1,
_ => {}
}
}
depth
}
fn annotation(file: &str, lines: &[&str], index: usize, start: usize) -> Vec<String> {
let mut out = Vec::new();
if let Some(opener) = comment_opener(file) {
if let Some(above) = index
.checked_sub(1)
.map(|previous| lines[previous].trim_start())
.filter(|previous| previous.starts_with(opener))
{
out.push(above.to_string());
}
}
out.push(lines[index][start..].to_string());
let mut depth = unbalanced(code_region(file, lines[index]));
let mut continuation = Vec::new();
let mut next = index + 1;
while depth > 0 && next < lines.len() {
continuation.push(lines[next].to_string());
depth += unbalanced(code_region(file, lines[next]));
next += 1;
}
if depth == 0 {
out.extend(continuation);
}
out
}
fn sites(ctx: &GateCtx) -> Result<Vec<Site>, GateError> {
let mut sites = Vec::new();
for file in walk_files(ctx) {
if file
.components()
.any(|part| part.as_str() == "known-issues")
{
continue;
}
let bytes =
std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
if looks_binary(&bytes) {
continue;
}
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
let name = file.as_str().trim_start_matches("./").to_string();
let lines: Vec<&str> = text.lines().collect();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
let kinds = if name.ends_with(".md") {
classify(&text)
} else {
Vec::new()
};
let live = live_from(&name, &lines);
for (index, line) in lines.iter().enumerate() {
if matches!(
kinds.get(index),
Some(LineKind::Fence | LineKind::FrontMatter)
) {
continue;
}
let Some(offset) = live[index] else { continue };
let Some(found) = suppression_at(&name, &line[offset..]) else {
continue;
};
let start = offset + found;
if !is_closing(line) {
sites.push(Site {
file: name.clone(),
number: index + 1,
line: (*line).to_string(),
annotation: annotation(&name, &lines, index, start),
});
}
}
}
Ok(sites)
}
pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
let sites = sites(ctx)?;
let mut violations = Vec::new();
let mut caseless = Vec::new();
for site in &sites {
match (site.cites_a_case(), site.reason()) {
(true, None) => {}
(false, Some(reason)) if !reason.is_empty() => {}
(false, Some(_)) => violations.push(Violation::Finding(Finding::on_line(
PERMANENT,
&site.file,
site.number,
"the permanent marker states no reason",
))),
(true, Some(_)) => violations.push(Violation::Finding(Finding::on_line(
PERMANENT,
&site.file,
site.number,
"names a case and states a permanent exception",
))),
(false, None) => caseless.push(site),
}
}
if !caseless.is_empty() {
violations.push(Violation::Finding(Finding::global(CASE, "")));
for site in caseless {
violations.push(Violation::Note(format!(
"./{}:{}:{}",
site.file, site.number, site.line
)));
}
}
let known: BTreeSet<String> = ki_records(ctx, args)?
.iter()
.filter_map(|record| {
record
.file_name()
.map(|name| name.trim_end_matches(".md").to_string())
})
.collect();
let cited: BTreeSet<String> = sites
.iter()
.flat_map(|site| site.annotation.iter().flat_map(|line| cited_cases(line)))
.collect();
for case in cited {
if !known.contains(&case) {
violations.push(Violation::Finding(Finding::global(
CASE,
format!("{case} resolves to no record"),
)));
}
}
Ok(violations)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let records = dir.path().join("_docs/reference/known-issues");
std::fs::create_dir_all(&records).unwrap();
std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
dir
}
fn run_on(name: &str, text: &str) -> Vec<String> {
let dir = fixture();
std::fs::write(dir.path().join(name), text).unwrap();
let ctx = GateCtx::new(dir.path().to_str().unwrap());
run(&ctx, &[])
.unwrap()
.iter()
.map(ToString::to_string)
.collect()
}
#[test]
fn a_repository_without_suppressions_passes() {
let dir = fixture();
let ctx = GateCtx::new(dir.path().to_str().unwrap());
assert!(run(&ctx, &[]).unwrap().is_empty());
}
#[test]
fn every_form_passes_when_it_names_a_record() {
for (name, text) in [
(
"local.md",
"<!-- markdownlint-disable MD013 KI-vendor-quirk -->\n",
),
("local.md", "<!-- dprint-ignore KI-vendor-quirk -->\n"),
("local.rs", "#[allow(dead_code)] // KI-vendor-quirk\n"),
("local.rs", "#[expect(dead_code)] // KI-vendor-quirk\n"),
("local.rs", "#[ignore = \"KI-vendor-quirk\"]\n"),
(
"local.sh",
"# shellcheck disable=SC2329 # KI-vendor-quirk\n",
),
("local.py", "x = 1 # noqa: E501 KI-vendor-quirk\n"),
("local.py", "x = 1 # type: ignore KI-vendor-quirk\n"),
(
"local.yml",
"on: push # zizmor: ignore[dangerous-triggers] KI-vendor-quirk\n",
),
(
"local.ts",
"// eslint-disable-next-line no-eval KI-vendor-quirk\n",
),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn every_form_fails_when_it_says_nothing() {
for (name, text) in [
("local.md", "<!-- markdownlint-disable MD013 -->\n"),
("local.rs", "#[allow(dead_code)]\n"),
("local.rs", "#![allow(clippy::unwrap_used)]\n"),
("local.sh", "# shellcheck disable=SC2329\n"),
("local.py", "x = 1 # noqa: E501\n"),
("local.ts", "// eslint-disable-next-line no-eval\n"),
] {
let out = run_on(name, text);
assert_eq!(out.len(), 2, "{name}: {text}");
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
assert!(out[1].contains(&format!("{name}:1")));
}
}
#[test]
fn a_permanent_marker_with_a_reason_passes() {
for (name, text) in [
(
"local.rs",
"// sdd: permanent the braces are a template placeholder\n#[allow(clippy::x)]\n",
),
(
"local.rs",
"#[allow(clippy::x)] // sdd: permanent the lint is wrong here\n",
),
(
"local.sh",
"# shellcheck disable=SC2329 # sdd: permanent reached through a trap\n",
),
(
"local.md",
"<!-- markdownlint-disable MD013 sdd: permanent the table is data -->\n",
),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn a_marker_on_the_line_above_a_multi_line_attribute_passes() {
let text = "// sdd: permanent a test module panics as its failure signal\n#![allow(\n clippy::unwrap_used\n)]\n";
assert!(run_on("local.rs", text).is_empty());
}
#[test]
fn a_non_comment_line_above_supplies_nothing() {
let text = "let reason = \"KI-vendor-quirk\";\n#[allow(dead_code)]\n";
let out = run_on("local.rs", text);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_marker_without_a_reason_is_rejected() {
for text in [
"#[allow(dead_code)] // sdd: permanent\n",
"// sdd: permanent\n#[allow(dead_code)]\n",
] {
let out = run_on("local.rs", text);
assert_eq!(out.len(), 1, "{text}");
assert!(
out[0].ends_with(": the permanent marker states no reason"),
"{text}"
);
}
}
#[test]
fn an_expected_failure_is_a_suppression() {
assert!(
run_on(
"test_x.py",
"@pytest.mark.xfail(reason=\"KI-vendor-quirk\", strict=True)\ndef test_x():\n pass\n"
)
.is_empty()
);
let out = run_on(
"test_x.py",
"@pytest.mark.xfail(strict=True)\ndef test_x():\n pass\n",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_form_inside_a_string_is_a_quotation() {
for (name, text) in [
("local.py", "value = \"# noqa: E501\"\n"),
("local.sh", "printf '%s' '# shellcheck disable=SC2329'\n"),
(
"local.ts",
"const form = \"// eslint-disable-next-line\";\n",
),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn a_fenced_example_in_a_document_is_a_quotation() {
for fence in ["```markdown", "~~~markdown", "````markdown"] {
let close = fence.trim_end_matches("markdown");
let text = format!(
"# Chapter\n\n{fence}\n<!-- markdownlint-disable MD013 -->\n{close}\n\nProse.\n"
);
assert!(run_on("chapter.md", &text).is_empty(), "{fence}");
}
}
#[test]
fn an_apostrophe_before_a_live_directive_does_not_hide_it() {
let out = run_on("local.py", "value = \"it's long\" # noqa: E501\n");
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_marker_inside_a_longer_word_is_not_the_marker() {
let out = run_on(
"local.py",
"x = 1 # noqa: E501 not-sdd: permanent reason\n",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_case_written_in_code_before_the_comment_is_not_the_suppressions() {
let out = run_on("local.py", "path = \"KI-vendor-quirk.md\" # noqa: E501\n");
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_multiline_expected_failure_carries_its_case() {
let text = "@pytest.mark.xfail(\n reason=\"KI-vendor-quirk\",\n strict=True,\n)\ndef test_x():\n pass\n";
assert!(run_on("test_x.py", text).is_empty());
}
#[test]
fn a_long_attribute_carries_its_case_past_any_line_count() {
let lints = " clippy::a_lint,\n".repeat(20);
let text = format!("#[allow(\n{lints} // KI-vendor-quirk\n)]\nfn f() {{}}\n");
assert!(run_on("local.rs", &text).is_empty());
}
#[test]
fn an_apostrophe_in_comment_prose_does_not_hide_a_later_directive() {
let out = run_on("local.py", "value = 1 # don't reflow # noqa: E501\n");
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn comment_punctuation_does_not_extend_the_annotation() {
let text = "#[allow(dead_code)] // (\nfn kept() {} // KI-vendor-quirk\n";
let out = run_on("local.rs", text);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_line_carrying_multibyte_text_is_scanned_without_panicking() {
assert!(run_on("local.py", "value = \"a 🤖 walks in\" # a note\n").is_empty());
let out = run_on("local.py", "value = \"a 🤖 walks in\" # noqa: E501\n");
assert_eq!(out.len(), 2);
}
#[test]
fn a_form_inside_a_multiline_string_is_a_quotation() {
for (name, text) in [
("local.py", "DOC = \"\"\"\n# noqa: E501\n\"\"\"\n"),
(
"local.ts",
"const doc = `\n// eslint-disable-next-line\n`;\n",
),
("local.ts", "const doc = `// eslint-disable-next-line`;\n"),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn a_linters_other_spellings_are_the_same_form() {
for text in [
"value = 1 # NOQA: E501\n",
"# ruff: noqa\n",
"# flake8: noqa\n",
] {
let out = run_on("local.py", text);
assert_eq!(out.len(), 2, "{text}");
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
}
#[test]
fn the_line_a_multiline_string_closes_on_is_still_content() {
for (name, text) in [
("local.py", "DOC = \"\"\"\n# noqa: E501 \"\"\"\n"),
("local.ts", "const doc = `\n// eslint-disable-next-line`;\n"),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn an_escaped_delimiter_closes_no_multiline_string() {
let text = "const t = `\nconst label = \\`value\\`;\n// eslint-disable-next-line\n`;\n";
assert!(run_on("local.ts", text).is_empty());
}
#[test]
fn a_suppression_after_a_closing_delimiter_is_live() {
let out = run_on(
"local.py",
"DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501\n",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
assert!(out[1].contains("local.py:3"));
let text = "DOC = \"\"\"\nlong text\n\"\"\" # noqa: E501 KI-vendor-quirk\n";
assert!(run_on("local.py", text).is_empty());
}
#[test]
fn a_quoted_fence_delimiter_opens_no_multiline_string() {
for opener in [
"delimiter = '\"\"\"'\n",
"# a docstring opens with \"\"\"\n",
] {
let out = run_on("local.py", &format!("{opener}value = 1 # noqa: E501\n"));
assert_eq!(out.len(), 2, "{opener}");
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
}
#[test]
fn a_token_that_runs_on_is_not_the_token() {
let out = run_on(
"local.py",
"x = 1 # noqa: E501 sdd: permanently justified\n",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
let out = run_on("local.rs", "#[allow(dead_code)] // KI-vendor-quirkXYZ\n");
assert_eq!(out.len(), 2);
assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
}
#[test]
fn a_case_and_a_marker_together_are_rejected() {
let out = run_on(
"local.rs",
"#[allow(dead_code)] // KI-vendor-quirk sdd: permanent both\n",
);
assert_eq!(out.len(), 1);
assert!(out[0].ends_with(": names a case and states a permanent exception"));
}
#[test]
fn a_case_resolving_to_no_record_is_rejected() {
let out = run_on(
"local.md",
"<!-- markdownlint-disable KI-absent-record -->\n",
);
assert_eq!(
out,
vec![
"FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
.to_string()
]
);
}
#[test]
fn a_form_named_outside_its_file_kind_is_a_quotation() {
for (name, text) in [
(
"prose.md",
"The `#[allow(dead_code)]` attribute suppresses a lint.\n",
),
(
"prose.md",
"A Python file carries `# noqa: E501` at the line.\n",
),
(
"local.rs",
"let form = \"<!-- markdownlint-disable -->\";\n",
),
("local.rs", "let form = \"# shellcheck disable=SC2329\";\n"),
("local.rs", "let form = \"// eslint-disable-next-line\";\n"),
] {
assert!(run_on(name, text).is_empty(), "{name}: {text}");
}
}
#[test]
fn closing_markers_are_not_suppressions() {
let text = "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n";
assert!(run_on("local.md", text).is_empty());
}
#[test]
fn a_vendored_tree_is_skipped() {
let dir = fixture();
let vendored = dir.path().join("third-party/upstream");
std::fs::create_dir_all(&vendored).unwrap();
std::fs::write(vendored.join("hook.py"), "x = 1 # noqa: E501\n").unwrap();
let ctx = GateCtx::new(dir.path().to_str().unwrap());
assert!(run(&ctx, &[]).unwrap().is_empty());
}
}