Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 159 additions & 1 deletion src/dayamlchecker/yaml_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,149 @@

DEFAULT_LINT_MODE = "default"
ACCESSIBILITY_LINT_MODE = "accessibility"
_SUPPRESSION_RE = re.compile(
r"#\s*no-dayc(?P<scope>-block)?\s*:\s*(?P<codes>[^#]+)",
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
Comment thread
nonprofittechy marked this conversation as resolved.

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)
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand Down
64 changes: 64 additions & 0 deletions tests/test_yaml_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<string_invalid>")
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="<string_invalid>")
self.assertTrue(_has_code(errs, "EG101"), f"Expected EG101 to remain: {errs}")
Comment thread
nonprofittechy marked this conversation as resolved.

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="<string_invalid>")
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="<string_invalid>")
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="<string_invalid>")
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="<string_invalid>")
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):
Expand Down
24 changes: 24 additions & 0 deletions tests/test_yaml_structure_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading