diff --git a/README.md b/README.md index 38dd8cd..f88a13d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,30 @@ pip install . python3 -m dayamlchecker `find . -name "*.yml" -path "*/questions/*" snot -path "*/.venv/*" -not -path "*/build/*"` # i.e. a space separated list of files ``` +## Suppressing checks + +You can suppress specific errors or warnings by their ID or finding class (`accessibility`, `style`, `translatability`, `general`). + +**Inline and block comments in YAML:** +To suppress a finding on a specific line, use a `# no-dayc: ` comment: +```yaml +question: Second # no-dayc: EG101 +``` +To suppress findings for an entire document or block, use `# no-dayc-block: ` inside the block (e.g., after the `---` document marker): +```yaml +--- +# no-dayc-block: style, WG123 +code: | + answer = 1 +``` +You can use `ALL` or `*` to suppress all findings on a line/block (`# no-dayc: ALL`). Multiple codes can be separated by spaces or commas. + +**Command-line argument:** +To globally suppress findings across all files being checked, pass a comma-separated list of IDs or classes to the `--suppress` parameter: +```bash +python3 -m dayamlchecker --suppress accessibility,EG101 path/to/interview.yml +``` + ## WCAG checks The checker includes WCAG-style checks for clear static accessibility failures in interview source. These checks run by default; use `--no-wcag` to disable them. diff --git a/src/dayamlchecker/yaml_structure.py b/src/dayamlchecker/yaml_structure.py index 382526f..fe0ae57 100644 --- a/src/dayamlchecker/yaml_structure.py +++ b/src/dayamlchecker/yaml_structure.py @@ -51,6 +51,149 @@ DEFAULT_LINT_MODE = "default" ACCESSIBILITY_LINT_MODE = "accessibility" +_SUPPRESSION_RE = re.compile( + r"#\s*no-dayc(?P-block)?\s*:\s*(?P[^#]+)", + re.IGNORECASE, +) +_SUPPRESS_ALL_CODES = frozenset({"*", "ALL"}) + + +def _parse_suppression_codes(raw_codes: str) -> frozenset[str]: + return frozenset( + code.strip().upper() for code in re.split(r"[\s,]+", raw_codes) if code.strip() + ) + + +def _finding_matches_suppression(finding: Finding, codes: frozenset[str]) -> bool: + if not codes: + return False + if codes & _SUPPRESS_ALL_CODES: + return True + return ( + finding.code.upper() in codes + or str(finding.message_id).upper() in codes + or str(finding.finding_class).upper() in codes + ) + + +def _document_line_ranges(full_content: str) -> list[tuple[int, int]]: + lines = full_content.splitlines() + ranges: list[tuple[int, int]] = [] + start_line = 1 + + for line_number, line in enumerate(lines, start=1): + if document_match.match(line): + if start_line <= line_number - 1: + ranges.append((start_line, line_number - 1)) + start_line = line_number + 1 + + if start_line <= len(lines): + ranges.append((start_line, len(lines))) + + return ranges + + +def _parse_dayc_suppressions( + full_content: str, +) -> tuple[dict[int, frozenset[str]], list[tuple[int, int, frozenset[str]]]]: + inline_by_line: dict[int, frozenset[str]] = {} + block_directives: list[tuple[int, frozenset[str]]] = [] + + for line_number, line in enumerate(full_content.splitlines(), start=1): + match = _SUPPRESSION_RE.search(line) + if not match: + continue + + codes = _parse_suppression_codes(match.group("codes")) + if not codes: + continue + + if match.group("scope"): + block_directives.append((line_number, codes)) + else: + inline_by_line[line_number] = ( + inline_by_line.get(line_number, frozenset()) | codes + ) + + block_ranges: list[tuple[int, int, frozenset[str]]] = [] + document_ranges = _document_line_ranges(full_content) + + for directive_line, codes in block_directives: + for start_line, end_line in document_ranges: + if start_line <= directive_line <= end_line: + block_ranges.append((start_line, end_line, codes)) + break + + return inline_by_line, block_ranges + + +def _finding_is_suppressed( + finding: Finding, + suppressions: tuple[ + dict[int, frozenset[str]], list[tuple[int, int, frozenset[str]]] + ], +) -> bool: + inline_by_line, block_ranges = suppressions + + if finding.line_number is None: + return any( + _finding_matches_suppression(finding, codes) for _, _, codes in block_ranges + ) + + inline_codes = inline_by_line.get(finding.line_number) + if inline_codes and _finding_matches_suppression(finding, inline_codes): + return True + + return any( + start_line <= finding.line_number <= end_line + and _finding_matches_suppression(finding, codes) + for start_line, end_line, codes in block_ranges + ) + + +def _apply_dayc_suppressions( + findings: list[Finding], full_content: str +) -> list[Finding]: + suppressions = _parse_dayc_suppressions(full_content) + return [ + finding + for finding in findings + if not _finding_is_suppressed(finding, suppressions) + ] + + +def _apply_dayc_suppressions_from_files(findings: list[Finding]) -> list[Finding]: + suppressions_by_file: dict[ + str, + tuple[ + dict[int, frozenset[str]], + list[tuple[int, int, frozenset[str]]], + ] + | None, + ] = {} + filtered: list[Finding] = [] + + for finding in findings: + file_name = finding.file_name + if not file_name or file_name.startswith("<"): + filtered.append(finding) + continue + + if file_name not in suppressions_by_file: + try: + suppressions_by_file[file_name] = _parse_dayc_suppressions( + Path(file_name).read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError): + suppressions_by_file[file_name] = None + + suppressions = suppressions_by_file[file_name] + if suppressions is not None and _finding_is_suppressed(finding, suppressions): + continue + + filtered.append(finding) + + return filtered @dataclass(frozen=True) @@ -1937,7 +2080,7 @@ def find_errors_from_string( options=style_options, ) ) - return all_errors + return _apply_dayc_suppressions(all_errors, full_content) _COMMON_COURTFORMSONLINE_METADATA_FIELDS = ( @@ -2175,6 +2318,12 @@ def main(argv: Optional[list[str]] = None) -> int: type=Path, help="YAML files or directories to validate (directories are searched recursively)", ) + parser.add_argument( + "--suppress", + action="append", + default=[], + help="Suppress a finding by ID or class. Can be a comma-separated list or passed multiple times.", + ) parser.add_argument( "--check-all", action="store_true", @@ -2365,6 +2514,15 @@ def main(argv: Optional[list[str]] = None) -> int: ) all_findings.extend(url_check_result.issues) + all_findings = _apply_dayc_suppressions_from_files(all_findings) + if args.suppress: + cli_suppressed_codes = _parse_suppression_codes(",".join(args.suppress)) + all_findings = [ + f + for f in all_findings + if not _finding_matches_suppression(f, cli_suppressed_codes) + ] + had_error = False warning_count = sum(1 for f in all_findings if f.severity == "warning") diff --git a/tests/test_yaml_structure.py b/tests/test_yaml_structure.py index de4cf92..14a9a2c 100644 --- a/tests/test_yaml_structure.py +++ b/tests/test_yaml_structure.py @@ -2171,6 +2171,70 @@ def test_mandatory_null_errors(self): f"Expected mandatory null error, got: {errs}", ) + def test_inline_suppression_hides_matching_code(self): + yaml_content = """question: First +question: Second # no-dayc: EG101 +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertFalse(_has_code(errs, "EG101"), f"Expected EG101 suppressed: {errs}") + + def test_inline_suppression_does_not_hide_other_codes(self): + yaml_content = """question: First +question: Second # no-dayc: WG999 +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertTrue(_has_code(errs, "EG101"), f"Expected EG101 to remain: {errs}") + + def test_block_suppression_hides_matching_code_in_document(self): + yaml_content = """--- +# no-dayc-block: WG123 +code: | + answer = 1 + def helper(): + return answer +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertFalse(_has_code(errs, "WG123"), f"Expected WG123 suppressed: {errs}") + + def test_block_suppression_does_not_leak_to_next_document(self): + yaml_content = """# no-dayc-block: WG123 +code: | + answer = 1 + def helper(): + return answer +--- +code: | + answer = 1 + def helper(): + return answer +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertEqual( + sum(err.code == "WG123" for err in errs), + 1, + f"Expected only second WG123 to remain: {errs}", + ) + + def test_inline_suppression_hides_matching_class(self): + yaml_content = """question: First +question: Second # no-dayc: general +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertFalse( + _has_code(errs, "EG101"), f"Expected EG101 suppressed by class: {errs}" + ) + + def test_block_suppression_hides_matching_class_in_document(self): + yaml_content = """--- +# no-dayc-block: general +question: First +question: Second +""" + errs = find_errors_from_string(yaml_content, input_file="") + self.assertFalse( + _has_code(errs, "EG101"), f"Expected EG101 suppressed by class: {errs}" + ) + class TestALLinterParityRules(unittest.TestCase): def test_missing_question_id_is_reported(self): diff --git a/tests/test_yaml_structure_cli.py b/tests/test_yaml_structure_cli.py index fc27314..6c7c011 100644 --- a/tests/test_yaml_structure_cli.py +++ b/tests/test_yaml_structure_cli.py @@ -428,3 +428,27 @@ def fake_run_url_check(**kwargs): assert captured["yaml_severity"] == "error" assert captured["document_severity"] == "ignore" assert captured["unreachable_severity"] == "error" + + +def test_main_cli_suppress_suppresses_findings(monkeypatch, tmp_path): + interview = tmp_path / "interview.yml" + interview.write_text("question: First\nquestion: Second\n") + + monkeypatch.setattr( + sys, + "argv", + ["dayamlchecker", "--suppress", "EG101", str(interview)], + ) + assert yaml_structure.main() == 0 + + +def test_main_cli_suppress_does_not_suppress_unmatched_findings(monkeypatch, tmp_path): + interview = tmp_path / "interview.yml" + interview.write_text("question: First\nquestion: Second\n") + + monkeypatch.setattr( + sys, + "argv", + ["dayamlchecker", "--suppress", "WG999", str(interview)], + ) + assert yaml_structure.main() == 1