Skip to content

Commit 2bb0ce7

Browse files
committed
fix(config): keep a file's per-file-ignores out of the workspace index it feeds
A run over files filtered each file's rules by its `per-file-ignores` before linting it, and the same filtered set built the index the cross-file checks resolve against. MD051 is the only rule that records a file's headings, so `"target.md" = ["MD051"]` erased target.md from the index and every link elsewhere pointing at one of its anchors reported as broken. The finding named a file and a rule the reader never configured, and the fragment it called missing was there in the target all along. Per-file-ignores decides what a file REPORTS. What a file contributes to the workspace belongs to the workspace: its headings answer links written in other files, whose own configuration says nothing about it. `lint_and_index` now applies the ignores to the rules it runs and keeps every cross-file rule for the index it builds, so the full rule set can go in. That is how the cache-hit path and the stdin path already build an index, so all three now agree. The ignore itself is unchanged: the file that names a rule reports nothing from it, on its own lines or through a cross-file check, and an embedded markdown block is part of that file so it is still linted with the reduced set. The persisted workspace index carries the old meaning, and content is what decides whether an entry is reused, so an unchanged file would have kept its gap and the fixed build would have gone on reporting the false positive from the cache the previous one wrote. Its format version is bumped to rebuild.
1 parent e215c65 commit 2bb0ce7

4 files changed

Lines changed: 93 additions & 22 deletions

File tree

src/file_processor/processing.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -908,28 +908,20 @@ pub fn process_file_with_index(
908908

909909
let lint_start = Instant::now();
910910

911-
// Filter rules based on per-file-ignores configuration
912-
let filtered_rules: Vec<_> = rumdl_lib::time_function!(
913-
"file: filter rules",
914-
if !ignored_rules_for_file.is_empty() {
915-
rules
916-
.iter()
917-
.filter(|rule| !ignored_rules_for_file.contains(rule.name()))
918-
.map(|r| dyn_clone::clone_box(&**r))
919-
.collect()
920-
} else {
921-
rules.to_vec()
922-
}
923-
);
924-
925911
// Determine flavor based on per-file-flavor overrides, global config, or file extension
926912
let flavor = config.get_flavor_for_file(Path::new(file_path));
927913

928-
// Use lint_and_index for single-file linting + index contribution
914+
// Use lint_and_index for single-file linting + index contribution.
915+
//
916+
// The full rule set goes in: `lint_and_index` applies this file's
917+
// per-file-ignores to what it reports, and keeps every cross-file rule for the
918+
// index it builds. Handing it a pre-filtered set instead erased this file's
919+
// headings from the workspace, so a link elsewhere pointing at one of them
920+
// reported as broken.
929921
let source_file = Some(std::path::PathBuf::from(file_path));
930922
let (warnings_result, file_index) = rumdl_lib::time_function!(
931923
"file: lint and index",
932-
rumdl_lib::lint_and_index(&content, &filtered_rules, verbose, flavor, source_file, Some(config))
924+
rumdl_lib::lint_and_index(&content, rules, verbose, flavor, source_file, Some(config))
933925
);
934926

935927
// Combine all warnings
@@ -938,6 +930,20 @@ pub fn process_file_with_index(
938930
// Check embedded markdown blocks if configured in code-block-tools
939931
// The special tool "rumdl" in [code-block-tools.languages.markdown] enables this
940932
if should_lint_embedded_markdown(&config.code_block_tools) {
933+
// An embedded block is part of this file, so its findings are this file's
934+
// and per-file-ignores decides which of them are reported.
935+
let filtered_rules: Vec<_> = rumdl_lib::time_function!(
936+
"file: filter rules",
937+
if !ignored_rules_for_file.is_empty() {
938+
rules
939+
.iter()
940+
.filter(|rule| !ignored_rules_for_file.contains(rule.name()))
941+
.map(|r| dyn_clone::clone_box(&**r))
942+
.collect()
943+
} else {
944+
rules.to_vec()
945+
}
946+
);
941947
let embedded_warnings = rumdl_lib::time_function!(
942948
"file: embedded markdown blocks",
943949
check_embedded_markdown_blocks(&content, &filtered_rules, config)

src/lib.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,17 @@ pub fn lint_and_index(
403403
return (Ok(warnings), file_index);
404404
}
405405

406+
// The rules `per-file-ignores` takes away for this file. It decides what this
407+
// file REPORTS, and nothing else: the index contribution at the end of this
408+
// function deliberately keeps running every cross-file rule, because a file's
409+
// headings and links belong to the workspace rather than to its own report.
410+
// Dropping a rule from the index instead would break the links pointing HERE,
411+
// in files that never named it.
412+
let ignored_for_file = match (config, source_file.as_deref()) {
413+
(Some(cfg), Some(path)) => cfg.get_ignored_rules_for_file(path),
414+
_ => std::collections::HashSet::new(),
415+
};
416+
406417
// Parse LintContext once (includes inline config parsing)
407418
let lint_ctx = time_function!(
408419
"lint: parse lint context",
@@ -422,9 +433,10 @@ pub fn lint_and_index(
422433
ContentCharacteristics::analyze(content)
423434
);
424435

425-
// Filter rules based on content characteristics
436+
// Filter rules based on per-file-ignores and content characteristics
426437
let applicable_rules: Vec<_> = rules
427438
.iter()
439+
.filter(|rule| !ignored_for_file.contains(rule.name()))
428440
.filter(|rule| !(rule.skippable_by_category() && characteristics.should_skip_rule(rule.as_ref())))
429441
.collect();
430442

@@ -527,12 +539,14 @@ pub fn lint_and_index(
527539

528540
// A workspace-scope rule has its warnings filtered after this point, and for a
529541
// single-file run not at all, so its findings never reach the report and
530-
// nothing can be concluded about a comment naming it.
542+
// nothing can be concluded about a comment naming it. A rule this file
543+
// ignores does not report either, for the same reason.
531544
let report = crate::rule::SuppressionReport {
532545
suppressed,
533546
judged_rules: rules
534547
.iter()
535548
.filter(|rule| rule.cross_file_scope() != crate::rule::CrossFileScope::Workspace)
549+
.filter(|rule| !ignored_for_file.contains(rule.name()))
536550
.map(|rule| rule.name().to_string())
537551
.collect(),
538552
};
@@ -555,9 +569,10 @@ pub fn lint_and_index(
555569
// Contribute to index for cross-file rules (done after all rules checked)
556570
// NOTE: We iterate over ALL rules (not just applicable_rules) because cross-file
557571
// rules need to extract data from every file in the workspace, regardless of whether
558-
// that file has content that would trigger the rule. For example, MD051 needs to
559-
// index headings from files that have no links (like target.md) so that links
560-
// FROM other files TO those headings can be validated.
572+
// that file has content that would trigger the rule, and regardless of what this
573+
// file's own configuration reports. For example, MD051 needs to index headings from
574+
// files that have no links (like target.md) so that links FROM other files TO those
575+
// headings can be validated - including from a file that ignores MD051 itself.
561576
time_section!("lint: contribute cross-file data", {
562577
for rule in rules {
563578
if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {

src/workspace_index.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,14 @@ const CACHE_MAGIC: &[u8; 4] = b"RWSI";
289289
/// bytes still decode, so nothing here would notice, and a cached index is
290290
/// reused whole when a file's content is unchanged - a version 9 cache would
291291
/// keep reporting the duplicate this version exists to stop.
292+
///
293+
/// Version 11 is the same shape of change: a file's entry no longer depends on
294+
/// its own `per-file-ignores`, so an entry written before it can be missing the
295+
/// headings an ignored rule would have recorded. Content is what decides reuse,
296+
/// and the content did not change, so without this the fixed build would keep
297+
/// serving the false positive from the cache the old one left behind.
292298
#[cfg(feature = "postcard")]
293-
const CACHE_FORMAT_VERSION: u32 = 10;
299+
const CACHE_FORMAT_VERSION: u32 = 11;
294300

295301
/// Cache file name within the version directory
296302
#[cfg(feature = "postcard")]

tests/config/per_file_ignores_integration_test.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,47 @@ fn test_inline_enable_of_per_file_ignored_rule_denies() {
600600
String::from_utf8_lossy(&plain.stderr)
601601
);
602602
}
603+
604+
/// `per-file-ignores` decides what a file reports, not what the workspace knows
605+
/// about it. A target that ignores MD051 still has to contribute its headings,
606+
/// or a link pointing at one of them reports as broken from a file that never
607+
/// named the rule - a false positive whose cause is written in another file's
608+
/// configuration.
609+
#[test]
610+
fn test_per_file_ignores_on_a_target_keeps_its_headings_indexed() {
611+
let temp_dir = tempdir().unwrap();
612+
613+
fs::write(
614+
temp_dir.path().join(".rumdl.toml"),
615+
r#"
616+
[per-file-ignores]
617+
"target.md" = ["MD051"]
618+
"#,
619+
)
620+
.unwrap();
621+
fs::write(temp_dir.path().join("target.md"), "# Real\n").unwrap();
622+
fs::write(
623+
temp_dir.path().join("source.md"),
624+
"# Source\n\n[valid](target.md#real)\n\n[broken](target.md#missing)\n",
625+
)
626+
.unwrap();
627+
628+
let stdout = run_check(temp_dir.path(), &["."]);
629+
630+
assert!(
631+
!stdout.contains("'real' not found"),
632+
"the anchor target.md does have must resolve. stdout={stdout}"
633+
);
634+
// The positive control: MD051 is still running for source.md, so the absence
635+
// above is the fragment resolving rather than the check declining to look.
636+
assert!(
637+
stdout.contains("'missing' not found"),
638+
"a fragment target.md does not have must still report. stdout={stdout}"
639+
);
640+
// And the ignore itself still holds for the file that named it: target.md's
641+
// own MD051 findings stay out of the report.
642+
assert!(
643+
!reports(&stdout, "target.md", "MD051"),
644+
"target.md must report no MD051 of its own. stdout={stdout}"
645+
);
646+
}

0 commit comments

Comments
 (0)