From c77de10741df315148ba71cfaeff16471749380a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 15:21:23 -0400 Subject: [PATCH] Detect track changes; fail yamlchecker if a document with track changes is in the templates directory --- README.md | 8 ++ src/dayamlchecker/docx_accessibility.py | 105 +++++++++++++++++++++ src/dayamlchecker/messages.py | 11 +++ src/dayamlchecker/yaml_structure.py | 12 +++ tests/test_docx_accessibility.py | 120 +++++++++++++++++++++++- 5 files changed, 254 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5e1b74..c77a990 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,14 @@ table header and merged-cell risks, explicitly low-contrast text, and floating objects or text boxes that disturb reading order. These run by default; use `--no-docx-accessibility` to skip them. +DOCX files are also checked for embedded comments and tracked-change markup. +These are errors by default because drafting material can leak into published +documents or change their output. Accept or reject all changes and remove all +comments before committing a template. Use `--no-docx-review-markup` to disable +only this rule while keeping the accessibility checks, or suppress `EG130` with +the standard `--suppress` option. The existing `--no-docx-accessibility` master +switch disables all DOCX checks, including this one. + **Every finding is capped at warning severity by default**, so turning these checks on reports problems without failing the build. Most existing templates have findings today, and the intent is for authors to work through diff --git a/src/dayamlchecker/docx_accessibility.py b/src/dayamlchecker/docx_accessibility.py index d11b74a..fca89f0 100644 --- a/src/dayamlchecker/docx_accessibility.py +++ b/src/dayamlchecker/docx_accessibility.py @@ -72,6 +72,51 @@ # a caption fragment) that having no heading styles is not a real finding. _HEADING_REQUIRED_TEXT_LENGTH = 1500 +_WORDPROCESSINGML_NAMESPACES = { + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://purl.oclc.org/ooxml/wordprocessingml/main", +} +_COMMENT_MARKUP_NAMES = { + "comment", + "commentRangeStart", + "commentRangeEnd", + "commentReference", +} +_TRACKED_CHANGE_NAMES = { + "ins", + "del", + "delText", + "delInstrText", + "moveFrom", + "moveTo", + "moveFromRangeStart", + "moveFromRangeEnd", + "moveToRangeStart", + "moveToRangeEnd", + "customXmlInsRangeStart", + "customXmlInsRangeEnd", + "customXmlDelRangeStart", + "customXmlDelRangeEnd", + "customXmlMoveFromRangeStart", + "customXmlMoveFromRangeEnd", + "customXmlMoveToRangeStart", + "customXmlMoveToRangeEnd", + "conflictIns", + "conflictDel", + "numberingChange", + "cellIns", + "cellDel", + "cellMerge", + "pPrChange", + "rPrChange", + "sectPrChange", + "tblPrChange", + "tblPrExChange", + "tblGridChange", + "trPrChange", + "tcPrChange", +} + _SEVERITY_RANK = {Severity.INFO: 0, Severity.WARNING: 1, Severity.ERROR: 2} @@ -161,6 +206,60 @@ def check_docx_accessibility( ] +def check_docx_review_markup(path: str | Path) -> list[Finding]: + """Return an error when a DOCX still contains comments or revisions. + + This is intentionally independent from the accessibility severity ceiling: + unresolved review markup can expose drafting material or produce unintended + output, so it is a default-on release-safety check. + """ + path = Path(path) + comment_parts: set[str] = set() + revision_parts: set[str] = set() + + try: + with zipfile.ZipFile(path) as package: + for part_name in package.namelist(): + if not part_name.startswith("word/") or not part_name.endswith(".xml"): + continue + try: + root = ET.fromstring(package.read(part_name)) + except (ET.ParseError, KeyError): + continue + for element in root.iter(): + if _namespace(element.tag) not in _WORDPROCESSINGML_NAMESPACES: + continue + local_name = _local_name(element.tag) + if local_name in _COMMENT_MARKUP_NAMES: + comment_parts.add(part_name) + if local_name in _TRACKED_CHANGE_NAMES: + revision_parts.add(part_name) + except (OSError, zipfile.BadZipFile): + # The accessibility package check owns the existing unreadable-DOCX + # diagnostic. Avoid reporting a misleading review-markup error too. + return [] + + markup = [] + if comment_parts: + markup.append("embedded comments") + if revision_parts: + markup.append("tracked changes") + if not markup: + return [] + + parts = sorted(comment_parts | revision_parts) + return [ + Finding( + message_id=MessageId.DOCX_REVIEW_MARKUP, + file_name=str(path), + context={ + "markup": " and ".join(markup), + "parts": ", ".join(parts), + }, + ) + ] + + def collect_docx_files( paths: list[Path], include_default_ignores: bool = True ) -> list[Path]: @@ -1110,6 +1209,12 @@ def _local_name(tag: str) -> str: return tag +def _namespace(tag: str) -> str: + if tag.startswith("{") and "}" in tag: + return tag[1:].split("}", 1)[0] + return "" + + def _attr(element: Optional[ET.Element], local_name: str) -> str: if element is None: return "" diff --git a/src/dayamlchecker/messages.py b/src/dayamlchecker/messages.py index 4e0d33a..7230652 100644 --- a/src/dayamlchecker/messages.py +++ b/src/dayamlchecker/messages.py @@ -80,6 +80,7 @@ class MessageId(StrEnum): MULTIPLE_MANDATORY_BLOCKS = "multiple_mandatory_blocks" MISSING_METADATA_FIELDS = "missing_metadata_fields" ATTACHMENT_CONDITIONAL_VARIABLE = "attachment_conditional_variable" + DOCX_REVIEW_MARKUP = "docx_review_markup" ACCESSIBILITY_COMBOBOX_NOT_ACCESSIBLE = "accessibility_combobox_not_accessible" ACCESSIBILITY_NO_LABEL_MULTI_FIELD = "accessibility_no_label_multi_field" @@ -965,6 +966,16 @@ class MessageDefinition: "displayed content" ), ), + MessageId.DOCX_REVIEW_MARKUP: MessageDefinition( + code="EG130", + severity=Severity.ERROR, + finding_class=FindingClass.GENERAL, + summary="DOCX contains review markup", + template=( + "the document contains {markup}; accept or reject tracked changes and " + "remove comments before publishing (found in {parts})" + ), + ), # DOCX template accessibility MessageId.ACCESSIBILITY_DOCX_DOCX_UNREADABLE: MessageDefinition( code="EA540", diff --git a/src/dayamlchecker/yaml_structure.py b/src/dayamlchecker/yaml_structure.py index df819e2..6210576 100644 --- a/src/dayamlchecker/yaml_structure.py +++ b/src/dayamlchecker/yaml_structure.py @@ -46,6 +46,7 @@ from dayamlchecker.docx_accessibility import ( DocxAccessibilityOptions, check_docx_accessibility, + check_docx_review_markup, collect_docx_files, ) @@ -2842,6 +2843,15 @@ def main(argv: Optional[list[str]] = None) -> int: "error to fail on documents with accessibility errors" ), ) + parser.add_argument( + "--docx-review-markup", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Fail when DOCX templates contain comments or tracked changes " + "(default: on)" + ), + ) parser.add_argument( "--format", choices=("text", "github"), @@ -2907,6 +2917,8 @@ def main(argv: Optional[list[str]] = None) -> int: docx_options = runtime_options.docx_accessibility_options() for docx_file in docx_files: all_findings.extend(check_docx_accessibility(docx_file, docx_options)) + if args.docx_review_markup: + all_findings.extend(check_docx_review_markup(docx_file)) if args.url_check and yaml_files: url_check_root = ( diff --git a/tests/test_docx_accessibility.py b/tests/test_docx_accessibility.py index 6e2bb82..ff4f146 100644 --- a/tests/test_docx_accessibility.py +++ b/tests/test_docx_accessibility.py @@ -7,6 +7,7 @@ from dayamlchecker.docx_accessibility import ( DocxAccessibilityOptions, check_docx_accessibility, + check_docx_review_markup, ) from dayamlchecker.messages import Severity from dayamlchecker.yaml_structure import main @@ -259,6 +260,71 @@ def test_unreadable_docx_reports_a_package_finding(): assert strict[0].severity == ERROR +# --------------------------------------------------------------------------- +# Comments and tracked changes +# --------------------------------------------------------------------------- + + +def test_docx_comments_are_errors_by_default(): + with TemporaryDirectory() as tmp: + path = _build( + tmp, + "commented", + 'Draft' + '' + "", + ) + with zipfile.ZipFile(path, "a") as package: + package.writestr( + "word/comments.xml", + '' + '' + "Do not publish this note" + "", + ) + + findings = check_docx_review_markup(path) + + assert len(findings) == 1 + assert findings[0].code == "EG130" + assert findings[0].severity == ERROR + assert findings[0].finding_class == "general" + assert "embedded comments" in findings[0].message + assert "word/comments.xml" in findings[0].message + + +def test_docx_tracked_changes_are_errors_by_default(): + with TemporaryDirectory() as tmp: + path = _build( + tmp, + "revisions", + 'Old' + 'New', + ) + + findings = check_docx_review_markup(path) + + assert len(findings) == 1 + assert findings[0].code == "EG130" + assert "tracked changes" in findings[0].message + assert "word/document.xml" in findings[0].message + + +def test_track_changes_setting_without_revision_markup_is_allowed(): + with TemporaryDirectory() as tmp: + path = _build(tmp, "tracking-on", f"{PROSE}") + with zipfile.ZipFile(path, "a") as package: + package.writestr( + "word/settings.xml", + '' + '', + ) + + assert check_docx_review_markup(path) == [] + + # --------------------------------------------------------------------------- # Regressions # --------------------------------------------------------------------------- @@ -553,7 +619,7 @@ def test_cli_checks_docx_by_default_without_failing(): assert "WA541" in output, "image-alt-missing, demoted to a warning" -def test_cli_can_skip_docx_checks(): +def test_cli_can_skip_all_docx_checks(): with TemporaryDirectory() as tmp: path = Path(tmp) / "inaccessible.docx" _write_docx(path, _base_files(_inaccessible_document_xml())) @@ -562,7 +628,57 @@ def test_cli_can_skip_docx_checks(): ["--no-docx-accessibility", "--no-url-check", str(path)] ) - assert exit_code == 1, "nothing left to check" + assert exit_code == 1, "nothing is enabled to check the DOCX" + + +def test_cli_review_markup_fails_independently_of_accessibility_severity(): + with TemporaryDirectory() as tmp: + path = _build( + tmp, + "revisions", + 'New', + ) + + exit_code, output = _run_cli(["--no-url-check", str(path)]) + + assert exit_code == 1 + assert "EG130" in output + + +def test_cli_can_disable_docx_review_markup_rule(): + with TemporaryDirectory() as tmp: + path = _build( + tmp, + "revisions", + 'New', + ) + + exit_code, output = _run_cli( + [ + "--no-docx-review-markup", + "--no-url-check", + str(path), + ] + ) + + assert exit_code == 0, "warning-level accessibility findings do not fail" + assert "EG130" not in output + + +def test_cli_can_suppress_docx_review_markup_by_code(): + with TemporaryDirectory() as tmp: + path = _build( + tmp, + "revisions", + 'New', + ) + + exit_code, output = _run_cli( + ["--suppress", "EG130", "--no-url-check", str(path)] + ) + + assert exit_code == 0 + assert "EG130" not in output def test_cli_error_severity_fails_the_command():