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
26 changes: 14 additions & 12 deletions src/dayamlchecker/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class MessageId(StrEnum):
MISSING_QUESTION_ID = "missing_question_id"
MULTIPLE_MANDATORY_BLOCKS = "multiple_mandatory_blocks"
MISSING_METADATA_FIELDS = "missing_metadata_fields"
ATTACHMENT_CONDITIONAL_VARIABLE = "attachment_conditional_variable"

ACCESSIBILITY_COMBOBOX_NOT_ACCESSIBLE = "accessibility_combobox_not_accessible"
ACCESSIBILITY_NO_LABEL_MULTI_FIELD = "accessibility_no_label_multi_field"
Expand Down Expand Up @@ -544,6 +545,13 @@ class MessageDefinition:
"{fields}"
),
),
MessageId.ATTACHMENT_CONDITIONAL_VARIABLE: MessageDefinition(
code="EG416",
severity=Severity.ERROR,
finding_class=FindingClass.GENERAL,
summary="Attachment content references a conditionally asked variable",
template='attachment content references "{field_var}", but that field is only asked when certain conditions are met (show if/hide if). If those conditions are not met, the interview may get stuck',
),
MessageId.ACCESSIBILITY_COMBOBOX_NOT_ACCESSIBLE: MessageDefinition(
code="EA501",
severity=Severity.ERROR,
Expand Down Expand Up @@ -1194,24 +1202,18 @@ def make_finding(
context=context,
)


def escape_data(value: str) -> str:
return (
value
.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
)
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


def escape_property(value: str) -> str:
return (
escape_data(value)
.replace(":", "%3A")
.replace(",", "%2C")
)
return escape_data(value).replace(":", "%3A").replace(",", "%2C")


def print_github_annotation(d: Finding) -> None:
kind = "notice" if d.severity == Severity.INFO else d.severity.value

props = []

if getattr(d, "file_name", None):
Expand Down
122 changes: 117 additions & 5 deletions src/dayamlchecker/yaml_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
from dataclasses import dataclass, field, replace
from pathlib import Path
from pyexpat import features
import re
import sys

Expand Down Expand Up @@ -1479,6 +1480,90 @@ def _has_matching_guard(active_guards: list[str], expected_guards: list[str]) ->
return False


def _extract_mako_guards_by_line(content: str) -> dict[int, list[str]]:
"""Extract Mako % if conditions active at each line of a content string"""
guards_by_line: dict[int, list[str]] = {}
guard_stack: list[str] = []

for i, line in enumerate(content.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("% if ") or stripped.startswith("%if "):
cond = re.sub(r"^%\s*if\s+", "", stripped).rstrip(":")
guard_stack.append(cond)
elif stripped.startswith("% elif ") or stripped.startswith("%elif "):
if guard_stack:
guard_stack.pop()
cond = re.sub(r"^%\s*elif\s+", "", stripped).rstrip(":")
guard_stack.append(cond)
elif stripped in ("% endif", "%endif", "% endfor", "%endfor"):
if guard_stack:
guard_stack.pop()

if guard_stack:
guards_by_line[i] = list(guard_stack)

return guards_by_line


def _find_unmatched_attachment_content_references(
doc: dict[str, Any], conditional_fields: list[dict[str, Any]]
) -> list[tuple[str, int]]:
"""Check attachment/attachments content blocks for unconditional references to variables that are only conditionally asked"""
if not conditional_fields:
return []

content_blocks: list[str] = []

attachment = _get_case_insensitive(doc, "attachment")
attachments = _get_case_insensitive(doc, "attachments")

if isinstance(attachment, dict):
skip = attachment.get("skip undefined")
if not (skip is True or (isinstance(skip, str) and skip.lower() == "true")):
content = attachment.get("content")
if isinstance(content, str):
content_blocks.append(content)
elif isinstance(attachment, list):
for item in attachment:
if isinstance(item, dict):
skip = item.get("skip undefined")
if not (
skip is True or (isinstance(skip, str) and skip.lower() == "true")
):
content = item.get("content")
if isinstance(content, str):
content_blocks.append(content)

if isinstance(attachments, list):
for item in attachments:
if isinstance(item, dict):
skip = item.get("skip undefined")
if not (
skip is True or (isinstance(skip, str) and skip.lower() == "true")
):
content = item.get("content")
if isinstance(content, str):
content_blocks.append(content)

if not content_blocks:
return []

unmatched: list[tuple[str, int]] = []
for content in content_blocks:
mako_guards_by_line = _extract_mako_guards_by_line(content)
for conditional in conditional_fields:
field_var = conditional["field_var"]
expected_guards = conditional["guards"]
ref_lines = _find_variable_reference_lines(content, field_var)
for ref_line in ref_lines:
active_guards = mako_guards_by_line.get(ref_line, [])
if _has_matching_guard(active_guards, expected_guards):
continue
unmatched.append((field_var, ref_line))

return unmatched


def _find_unmatched_interview_order_references(
doc: dict[str, Any], conditional_fields: list[dict[str, Any]]
) -> list[tuple[str, int]]:
Expand Down Expand Up @@ -1583,6 +1668,7 @@ def find_errors_from_string(
yaml_parser = _make_yaml_parser()
prior_conditional_fields: list[dict[str, Any]] = []
seen_ids: dict[str, int] = {}
skip_undefined = False
parsed_docs: list[ParsedInterviewDocument] = []
has_yaml_parse_errors = False
line_number = 1
Expand Down Expand Up @@ -1737,6 +1823,28 @@ def find_errors_from_string(
)
)

features = _get_case_insensitive(doc, "features")
if isinstance(features, dict):
skip_val = features.get("skip undefined")
if skip_val is True or (
isinstance(skip_val, str) and skip_val.lower() == "true"
):
skip_undefined = True

if not skip_undefined:
unmatched_content_refs = _find_unmatched_attachment_content_references(
doc, prior_conditional_fields
)
for field_var, ref_line in unmatched_content_refs:
all_errors.append(
make_finding(
MessageId.ATTACHMENT_CONDITIONAL_VARIABLE,
file_name=input_file,
line_number=doc["__line__"] + line_number + ref_line,
field_var=field_var,
)
)

nesting_depth = _max_screen_visibility_nesting_depth(doc)
if nesting_depth > 2:
all_errors.append(
Expand Down Expand Up @@ -2198,7 +2306,7 @@ def main(argv: Optional[list[str]] = None) -> int:

had_error = False
warning_count = sum(1 for f in all_findings if f.severity == "warning")

if args.format == "github":
for finding in all_findings:
if finding.severity == "error":
Expand All @@ -2207,19 +2315,23 @@ def main(argv: Optional[list[str]] = None) -> int:
else:
error_count = sum(1 for f in all_findings if f.severity == "error")
info_count = sum(1 for f in all_findings if f.severity == "info")

if len(all_findings) == 0:
print("No issues found.")
else:
if info_count > 0:
print(f"Found {len(all_findings)} issues ({error_count} errors, {warning_count} warnings, {info_count} infos):")
print(
f"Found {len(all_findings)} issues ({error_count} errors, {warning_count} warnings, {info_count} infos):"
)
elif warning_count > 0:
print(f"Found {len(all_findings)} issues ({error_count} errors, {warning_count} warnings):")
print(
f"Found {len(all_findings)} issues ({error_count} errors, {warning_count} warnings):"
)
else:
print(f"Found {len(all_findings)} errors:")
for err in all_findings:
print(f"{err}")

if error_count > 0:
had_error = True

Expand Down
Loading
Loading