diff --git a/docuchango/validator.py b/docuchango/validator.py index 1668e0b..09b6b9d 100755 --- a/docuchango/validator.py +++ b/docuchango/validator.py @@ -870,8 +870,29 @@ def check_code_blocks(self): else: # Closing fence if remainder: - # Closing fence has extra text - INVALID - # Per CommonMark: closing fence should have no info string + # Closing fence has extra text - this might indicate an unclosed block earlier + # If the remainder looks like a language specifier (single word, lowercase/numbers), + # this is likely an opening fence that's being misinterpreted as closing + looks_like_language = remainder and " " not in remainder and len(remainder) <= 20 + + if looks_like_language and opening_line: + # Strong signal: unclosed block earlier + error = f"Unclosed code block starting at line {opening_line} (```{opening_language})" + doc.errors.append(error) + self.log(f" ✗ {doc.file_path.name} - Unclosed block at line {opening_line}") + error = f"Line {line_num}: This appears to be a new opening fence (```{remainder}), but was interpreted as a closing fence due to the unclosed block above" + doc.errors.append(error) + self.log(f" ℹ {doc.file_path.name}:{line_num} - Cascading error from unclosed block") + doc_invalid_blocks += 2 # Count both errors + total_invalid += 2 + # Reset state and treat this as opening fence + in_code_block = True + opening_line = line_num + opening_language = remainder + previous_line_blank = False + continue + + # Actual closing fence with extra text error = f"Line {line_num}: Closing code fence has extra text (```{remainder}), should be just ```" doc.errors.append(error) self.log(f" ✗ {doc.file_path.name}:{line_num} - Closing fence with text '```{remainder}'") diff --git a/tests/test_validator.py b/tests/test_validator.py index 60e47a5..8111b3a 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -102,3 +102,81 @@ def test_failing_fixtures(self, fixtures_dir, tmp_path): all_errors.extend(doc.errors) assert len(all_errors) > 0, f"Fixture {fixture_file.name} should fail but passed validation" + + def test_unclosed_code_fence_error_message(self, tmp_path): + """Test that unclosed code fence errors are clear and point to the root cause (Issue #31).""" + docs_root = tmp_path / "test_issue31" + target_dir = docs_root / "docs-cms" / "memos" + target_dir.mkdir(parents=True, exist_ok=True) + + # Create a document with an unclosed code fence that causes cascading errors + content = """--- +id: "memo-999" +slug: memo-999-test +title: "Test Unclosed Fence" +date: "2025-11-16" +author: "Test Author" +created: "2025-11-16" +updated: "2025-11-16" +tags: ["test"] +project_id: "test-project" +doc_uuid: "12345678-1234-4000-8000-123456789012" +--- + +## Section 1 + +Some content here. + +```markdown +This is markdown content. +More markdown content here. + +## Section 2 + +This should trigger confusion because the markdown fence above is unclosed. + +```text +This looks like a new code block. +``` + +## Section 3 + +More content. +""" + target_file = target_dir / "memo-999-test-unclosed.md" + target_file.write_text(content) + + validator = DocValidator(repo_root=docs_root, verbose=False) + validator.scan_documents() + validator.check_code_blocks() + + # Collect all errors + all_errors = [] + for doc in validator.documents: + all_errors.extend(doc.errors) + + # Should have at least 2 errors: unclosed block + cascading error + assert len(all_errors) >= 2, f"Expected at least 2 errors, got {len(all_errors)}: {all_errors}" + + # Line 18 is where the ```markdown code block starts in the test content above (after frontmatter) + unclosed_block_start_line = 18 + + # First error should mention the unclosed block at the original location + unclosed_errors = [ + e for e in all_errors if f"Unclosed code block starting at line {unclosed_block_start_line}" in e + ] + assert len(unclosed_errors) > 0, ( + f"Should detect unclosed block at line {unclosed_block_start_line}. Errors: {all_errors}" + ) + + # Should have a cascading error explanation + cascading_errors = [ + e for e in all_errors if "appears to be a new opening fence" in e and "interpreted as a closing fence" in e + ] + assert len(cascading_errors) > 0, f"Should explain cascading error. Errors: {all_errors}" + + # Should NOT have confusing "Closing fence has extra text" errors without context + confusing_errors = [ + e for e in all_errors if "Closing code fence has extra text" in e and "appears to be" not in e + ] + assert len(confusing_errors) == 0, f"Should not have confusing closing fence errors. Found: {confusing_errors}"