From c6965acefe69683cf976d876dab866fc8f553767 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 08:30:03 +0000 Subject: [PATCH 1/7] [AISOS-2490] Add cat-themed troubleshooting note below forge-poller curl watch block Detailed description: - Inserted a single-sentence troubleshooting note under the curl watch block in 'docs/getting-started.md'. - Positioned the note with exactly 4 spaces of indentation and separated by single blank lines to maintain markdown structure consistency. Closes: AISOS-2490 --- docs/getting-started.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index 4431ae04b..b5951326d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -106,6 +106,8 @@ For local development you have two options: -d '{"tickets": ["MYPROJ-123"]}' ``` + 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). + !!! note Disable signature validation in Forge's `.env` so the poller's forwarded events are accepted: ``` From 32b8ec8c15ae196bd9ef62f81a125ac4cd262218 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 08:42:00 +0000 Subject: [PATCH 2/7] [AISOS-2492] Implement automated pytest assertions for Getting Started documentation note integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed description: - Created tests/unit/test_documentation.py to automate Getting Started guide validations. - Parsed the '=== "forge-poller (recommended)"' section inside docs/getting-started.md. - Asserted the presence, 🐱 Unicode cat emoji prefix (BR-003), and exactly one-sentence structure (BR-001) of the troubleshooting note. - Added comprehensive unit tests for simulated failure conditions to verify robust validation. Closes: AISOS-2492 --- tests/unit/test_documentation.py | 177 +++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/unit/test_documentation.py diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py new file mode 100644 index 000000000..88badebf7 --- /dev/null +++ b/tests/unit/test_documentation.py @@ -0,0 +1,177 @@ +"""Unit tests for Getting Started documentation note integrity.""" + +from pathlib import Path + +import pytest + + +def extract_tab_content(md_content: str, tab_title: str) -> str | None: + """Extracts the content of a specific tab section from Markdown. + + A tab section starts with a line containing `=== "tab_title"`. + All subsequent lines belonging to this tab are indented (by at least 4 spaces) + or are blank lines. The tab section ends when a non-blank line with no leading + indentation (spaces or tabs) is encountered. + """ + lines = md_content.splitlines() + in_tab = False + tab_lines = [] + for line in lines: + if not in_tab: + if line.strip().startswith(f'=== "{tab_title}"'): + in_tab = True + continue + else: + # End of tab is a non-empty line starting with no spaces/tabs (e.g. another tab or heading) + if line.strip() != "" and not (line.startswith(" ") or line.startswith("\t")): + break + tab_lines.append(line) + + if not in_tab: + return None + return "\n".join(tab_lines) + + +def find_troubleshooting_note(tab_content: str) -> str | None: + """Finds the line containing the troubleshooting note in the extracted tab content. + + We locate the note by looking for the core text phrase 'HTTP 405 error'. + This allows us to find the note even if the emoji or other properties are incorrect + or missing, so that we can perform assertions on those properties. + """ + for line in tab_content.splitlines(): + stripped = line.strip() + if "HTTP 405 error" in stripped: + return stripped + return None + + +def validate_troubleshooting_note(md_content: str) -> None: + """Validates the markdown content of getting-started.md for the troubleshooting note. + + Raises AssertionError if any of the documentation integrity rules are violated. + """ + # 1. Extract content of === "forge-poller (recommended)" + tab_content = extract_tab_content(md_content, "forge-poller (recommended)") + assert tab_content is not None, ( + "Section === 'forge-poller (recommended)' was not found in the documentation." + ) + + # 2. Find the troubleshooting note in the extracted tab content + note = find_troubleshooting_note(tab_content) + assert note is not None, ( + "The troubleshooting note was not found inside the 'forge-poller (recommended)' tab section." + ) + + # 3. Verify it starts with the 🐱 Unicode cat emoji + assert note.startswith("🐱"), ( + "The troubleshooting note must start with a 🐱 Unicode cat emoji (BR-003)." + ) + + # 4. Verify it is exactly one sentence in length (no intermediate periods) + text_content = note[1:].strip() + assert text_content.endswith("."), "The troubleshooting note must end with a period." + assert "." not in text_content[:-1], ( + "The troubleshooting note must be exactly one sentence in length (no intermediate sentence-ending periods) (BR-001)." + ) + + +def test_getting_started_exists() -> None: + """Verify that the docs/getting-started.md file exists.""" + doc_path = Path(__file__).parents[2] / "docs" / "getting-started.md" + assert doc_path.exists(), "docs/getting-started.md file does not exist" + assert doc_path.is_file(), "docs/getting-started.md is not a file" + + +def test_getting_started_note_integrity() -> None: + """Verify the integrity of the troubleshooting note in the actual docs/getting-started.md file.""" + doc_path = Path(__file__).parents[2] / "docs" / "getting-started.md" + md_content = doc_path.read_text(encoding="utf-8") + validate_troubleshooting_note(md_content) + + +def test_validation_failure_missing_tab() -> None: + """Verify that validation fails if the recommended tab section is missing.""" + bad_md = """ +For local development you have two options: + +=== "forge-poller (not recommended)" + + Register the ticket you're testing: + + 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). +""" + with pytest.raises( + AssertionError, match="Section === 'forge-poller \\(recommended\\)' was not found" + ): + validate_troubleshooting_note(bad_md) + + +def test_validation_failure_missing_note() -> None: + """Verify that validation fails if the troubleshooting note is missing inside the recommended tab.""" + bad_md = """ +For local development you have two options: + +=== "forge-poller (recommended)" + + Register the ticket you're testing: +""" + with pytest.raises( + AssertionError, + match="The troubleshooting note was not found inside the 'forge-poller \\(recommended\\)'", + ): + validate_troubleshooting_note(bad_md) + + +def test_validation_failure_missing_emoji() -> None: + """Verify that validation fails if the 🐱 Unicode cat emoji is missing from the troubleshooting note.""" + bad_md = """ +For local development you have two options: + +=== "forge-poller (recommended)" + + Register the ticket you're testing: + + If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). +""" + with pytest.raises( + AssertionError, match="The troubleshooting note must start with a 🐱 Unicode cat emoji" + ): + validate_troubleshooting_note(bad_md) + + +def test_validation_failure_not_one_sentence() -> None: + """Verify that validation fails if the troubleshooting note contains multiple sentences.""" + bad_md = """ +For local development you have two options: + +=== "forge-poller (recommended)" + + Register the ticket you're testing: + + 🐱 If you receive an HTTP 405 error. You likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). +""" + with pytest.raises( + AssertionError, match="The troubleshooting note must be exactly one sentence in length" + ): + validate_troubleshooting_note(bad_md) + + +def test_validation_failure_placed_incorrectly() -> None: + """Verify that validation fails if the troubleshooting note is placed in the wrong section.""" + bad_md = """ +For local development you have two options: + +=== "forge-poller (recommended)" + + Register the ticket you're testing: + +=== "ngrok (tunnel)" + + 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). +""" + with pytest.raises( + AssertionError, + match="The troubleshooting note was not found inside the 'forge-poller \\(recommended\\)'", + ): + validate_troubleshooting_note(bad_md) From 0fe4352b4b302dcd07f5eab4478367eea8de1605 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 08:47:06 +0000 Subject: [PATCH 3/7] [AISOS-2493] Add repository integrity test to verify changes are restricted to the documentation scope Detailed description: - Added 'test_exclusive_documentation_scope' to 'tests/unit/test_documentation.py' to verify no files outside 'docs/' and 'tests/' directories are modified or staged. - Implemented robust helper functions 'is_path_in_allowed_directories' and 'parse_git_status_line' to parse the output of 'git status --porcelain'. - Added comprehensive unit tests for the scope checker covering empty status, modifications within allowed/disallowed folders, and renames/moves. Closes: AISOS-2493 --- tests/unit/test_documentation.py | 119 +++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index 88badebf7..b0cdd5a4f 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -1,5 +1,6 @@ """Unit tests for Getting Started documentation note integrity.""" +import subprocess from pathlib import Path import pytest @@ -175,3 +176,121 @@ def test_validation_failure_placed_incorrectly() -> None: match="The troubleshooting note was not found inside the 'forge-poller \\(recommended\\)'", ): validate_troubleshooting_note(bad_md) + + +def is_path_in_allowed_directories(path_str: str) -> bool: + """Check if the given relative path is within the allowed directories (docs/ or tests/).""" + # Normalize path separator to forward slash + path_str = path_str.replace("\\", "/") + return path_str.startswith("docs/") or path_str.startswith("tests/") + + +def parse_git_status_line(line: str) -> list[str]: + """Parse a single line from 'git status --porcelain' and return the file path(s) involved.""" + if len(line) < 4: + return [] + # In porcelain format, the status is the first 2 characters, followed by a space. + status = line[:2] + path_part = line[3:] + + # Handle rename/copy which has " -> " separator + if (status.startswith("R") or status.startswith("C")) and " -> " in path_part: + parts = path_part.split(" -> ") + return [p.strip("\"' ") for p in parts] + + return [path_part.strip("\"' ")] + + +def check_exclusive_documentation_scope(git_status_output: str) -> None: + """Verifies that all modified or staged files are restricted to docs/ and tests/ directories. + + Raises AssertionError if any file outside of docs/ and tests/ directories is modified or staged. + """ + violating_files = [] + for line in git_status_output.splitlines(): + if not line.strip(): + continue + paths = parse_git_status_line(line) + for path in paths: + if not is_path_in_allowed_directories(path): + violating_files.append((line, path)) + + if violating_files: + details = "\n".join( + f"- Line: '{line.strip()}' (Parsed Path: '{path}')" for line, path in violating_files + ) + raise AssertionError( + f"Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories.\n" + f"The following violating files were modified or staged:\n{details}" + ) + + +def test_exclusive_documentation_scope() -> None: + """Verify that no files outside of docs/ and tests/ directories have been modified or staged (BR-002).""" + # Execute git status --porcelain + result = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + + # Verify using our validator + check_exclusive_documentation_scope(result.stdout) + + +def test_exclusive_scope_success_empty() -> None: + """Verify that empty git status output passes the scope validation.""" + check_exclusive_documentation_scope("") + + +def test_exclusive_scope_success_docs_and_tests() -> None: + """Verify that modifications restricted to docs/ and tests/ pass the scope validation.""" + status_output = ( + " M docs/getting-started.md\n" + "A tests/unit/test_documentation.py\n" + "?? tests/unit/new_test_file.py\n" + ) + check_exclusive_documentation_scope(status_output) + + +def test_exclusive_scope_failure_src() -> None: + """Verify that modifications in src/ cause scope validation failure.""" + status_output = " M docs/getting-started.md\n M src/forge/main.py\n" + with pytest.raises( + AssertionError, + match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", + ) as exc_info: + check_exclusive_documentation_scope(status_output) + + assert "src/forge/main.py" in str(exc_info.value) + + +def test_exclusive_scope_failure_config() -> None: + """Verify that modifications in root configuration files cause scope validation failure.""" + status_output = " M pyproject.toml\n" + with pytest.raises( + AssertionError, + match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", + ) as exc_info: + check_exclusive_documentation_scope(status_output) + + assert "pyproject.toml" in str(exc_info.value) + + +def test_exclusive_scope_rename_success() -> None: + """Verify that renames restricted to docs/ pass the scope validation.""" + status_output = "R docs/old-doc.md -> docs/new-doc.md\n" + check_exclusive_documentation_scope(status_output) + + +def test_exclusive_scope_rename_failure() -> None: + """Verify that renames moving files outside of docs/ and tests/ cause scope validation failure.""" + status_output = "R docs/getting-started.md -> src/getting-started.md\n" + with pytest.raises( + AssertionError, + match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", + ) as exc_info: + check_exclusive_documentation_scope(status_output) + + assert "src/getting-started.md" in str(exc_info.value) From 917e0c88024e4e8b305d96218bb84cbad776127d Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 08:53:29 +0000 Subject: [PATCH 4/7] [AISOS-2493] Add repository integrity test to verify changes are restricted to the documentation scope Auto-committed by Forge container fallback. --- tests/unit/test_documentation.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index b0cdd5a4f..72e9480ae 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -194,7 +194,7 @@ def parse_git_status_line(line: str) -> list[str]: path_part = line[3:] # Handle rename/copy which has " -> " separator - if (status.startswith("R") or status.startswith("C")) and " -> " in path_part: + if ("R" in status or "C" in status) and " -> " in path_part: parts = path_part.split(" -> ") return [p.strip("\"' ") for p in parts] @@ -294,3 +294,19 @@ def test_exclusive_scope_rename_failure() -> None: check_exclusive_documentation_scope(status_output) assert "src/getting-started.md" in str(exc_info.value) + + +def test_exclusive_scope_rename_with_different_status_codes() -> None: + """Verify that renames with different status codes (e.g., RM, R) are parsed correctly.""" + # Renamed in worktree with space prefix " R" + status_output_1 = " R docs/old-doc.md -> docs/new-doc.md\n" + check_exclusive_documentation_scope(status_output_1) + + # Renamed and modified "RM" + status_output_2 = "RM docs/old-doc.md -> docs/new-doc.md\n" + check_exclusive_documentation_scope(status_output_2) + + # Rename failure with different status code + status_output_3 = " R docs/getting-started.md -> src/getting-started.md\n" + with pytest.raises(AssertionError): + check_exclusive_documentation_scope(status_output_3) From 4aad5ab432ca011318d355c7e199bb92a10b7925 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 08:56:06 +0000 Subject: [PATCH 5/7] [AISOS-2494] Execute and verify clean compilation of documentation using Zensical build pipeline Detailed description: - Added automated test_zensical_build_clean_compilation to verify Zensical documentation build system is fully healthy. - Configured PATH to include uv by symlinking local virtual environment uv to /home/codespace/.local/bin/uv. - Verified compilation outputs, ensuring zensical builds with zero warnings or errors. Closes: AISOS-2494 --- tests/unit/test_documentation.py | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index 72e9480ae..0dedccbaf 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -310,3 +310,84 @@ def test_exclusive_scope_rename_with_different_status_codes() -> None: status_output_3 = " R docs/getting-started.md -> src/getting-started.md\n" with pytest.raises(AssertionError): check_exclusive_documentation_scope(status_output_3) + + +def test_zensical_build_clean_compilation() -> None: + """Verify that the documentation site compiles cleanly using the Zensical build pipeline.""" + # Run the compilation command: uv run --extra docs zensical build + result = subprocess.run( + ["uv", "run", "--extra", "docs", "zensical", "build"], + capture_output=True, + text=True, + ) + + # If uv is not available on PATH for some reason, run with absolute path of uv if possible + if result.returncode == 127: + root_dir = Path(__file__).parents[2] + uv_path = root_dir / ".venv" / "bin" / "uv" + if uv_path.exists(): + result = subprocess.run( + [str(uv_path), "run", "--extra", "docs", "zensical", "build"], + capture_output=True, + text=True, + ) + + # 1. Running uv run --extra docs zensical build returns exit code 0 + assert result.returncode == 0, ( + f"Zensical build failed with exit code {result.returncode}.\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + # 2. No compilation warnings or syntax errors are reported during the build. + stderr_lines = result.stderr.splitlines() + filtered_stderr = [] + for line in stderr_lines: + line_lower = line.lower() + # Exclude typical uv setup / performance / cache / notice messages + if "warning: failed to hardlink files" in line_lower: + continue + if "warning: if the cache and target" in line_lower: + continue + if "warning: if this is intentional" in line_lower: + continue + if "notice: a new release of pip" in line_lower: + continue + # Also exclude virtualenv / downloading output from uv + if line_lower.startswith("downloading ") or line_lower.startswith(" downloaded "): + continue + if "uninstalled" in line_lower and "package" in line_lower: + continue + if "installed" in line_lower and "package" in line_lower: + continue + if "built forge" in line_lower or "building forge" in line_lower: + continue + filtered_stderr.append(line) + + filtered_stderr_str = "\n".join(filtered_stderr) + + assert "error" not in filtered_stderr_str.lower(), ( + f"Zensical build reported errors:\n{filtered_stderr_str}" + ) + assert "warning" not in filtered_stderr_str.lower(), ( + f"Zensical build reported warnings:\n{filtered_stderr_str}" + ) + + # 3. The built static documentation site is correctly generated under the configured output directory. + root_dir = Path(__file__).parents[2] + site_dir = root_dir / "site" + assert site_dir.exists() and site_dir.is_dir(), ( + f"Built documentation directory '{site_dir}' does not exist or is not a directory." + ) + + getting_started_index = site_dir / "getting-started" / "index.html" + assert getting_started_index.exists() and getting_started_index.is_file(), ( + f"Built documentation for getting-started is missing: '{getting_started_index}'" + ) + + # Verify that the cat troubleshooting note is in getting_started_index + content = getting_started_index.read_text(encoding="utf-8") + assert "🐱" in content, "The Unicode cat emoji '🐱' was not found in the compiled HTML." + assert "HTTP 405 error" in content, ( + "The troubleshooting note text was not found in the compiled HTML." + ) From d4672202777731ff86c2f8c2b0ccbc8dad7ee7fd Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 09:00:31 +0000 Subject: [PATCH 6/7] [AISOS-2486-review] Fix breaking issues in local documentation test Detailed description: - Updated test_zensical_build_clean_compilation in tests/unit/test_documentation.py to robustly find and run uv, resolving FileNotFoundError when uv is not in system PATH but exists under .venv. - Modified uv warnings filter to correctly match and strip warning lines without requiring a 'warning: ' prefix, resolving test failures in environments with multi-filesystem mounts. Closes: AISOS-2486-review --- tests/unit/test_documentation.py | 39 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index 0dedccbaf..0bae24e55 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -315,22 +315,23 @@ def test_exclusive_scope_rename_with_different_status_codes() -> None: def test_zensical_build_clean_compilation() -> None: """Verify that the documentation site compiles cleanly using the Zensical build pipeline.""" # Run the compilation command: uv run --extra docs zensical build - result = subprocess.run( - ["uv", "run", "--extra", "docs", "zensical", "build"], - capture_output=True, - text=True, - ) - - # If uv is not available on PATH for some reason, run with absolute path of uv if possible - if result.returncode == 127: - root_dir = Path(__file__).parents[2] - uv_path = root_dir / ".venv" / "bin" / "uv" - if uv_path.exists(): - result = subprocess.run( - [str(uv_path), "run", "--extra", "docs", "zensical", "build"], - capture_output=True, - text=True, - ) + root_dir = Path(__file__).parents[2] + uv_path = root_dir / ".venv" / "bin" / "uv" + + # Try using the absolute path of uv in .venv first if it exists, otherwise fall back to system 'uv' + if uv_path.exists(): + cmd = [str(uv_path), "run", "--extra", "docs", "zensical", "build"] + else: + cmd = ["uv", "run", "--extra", "docs", "zensical", "build"] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + ) + except FileNotFoundError: + pytest.fail("Neither .venv/bin/uv nor system 'uv' was found on the path.") # 1. Running uv run --extra docs zensical build returns exit code 0 assert result.returncode == 0, ( @@ -345,11 +346,11 @@ def test_zensical_build_clean_compilation() -> None: for line in stderr_lines: line_lower = line.lower() # Exclude typical uv setup / performance / cache / notice messages - if "warning: failed to hardlink files" in line_lower: + if "failed to hardlink" in line_lower: continue - if "warning: if the cache and target" in line_lower: + if "if the cache and target" in line_lower: continue - if "warning: if this is intentional" in line_lower: + if "if this is intentional, set" in line_lower or "suppress this warning" in line_lower: continue if "notice: a new release of pip" in line_lower: continue From 59f3c49aa75feb5d07402af9fee69b24a35b9eee Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 3 Sep 2026 09:39:22 +0000 Subject: [PATCH 7/7] [AISOS-2486] review: address PR feedback --- docs/getting-started.md | 2 +- tests/unit/test_documentation.py | 394 ------------------------------- 2 files changed, 1 insertion(+), 395 deletions(-) delete mode 100644 tests/unit/test_documentation.py diff --git a/docs/getting-started.md b/docs/getting-started.md index b5951326d..03a8798c3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -106,7 +106,7 @@ For local development you have two options: -d '{"tickets": ["MYPROJ-123"]}' ``` - 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). + 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (default: 8000) instead of the forge-poller port (default: 8001). !!! note Disable signature validation in Forge's `.env` so the poller's forwarded events are accepted: diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py deleted file mode 100644 index 0bae24e55..000000000 --- a/tests/unit/test_documentation.py +++ /dev/null @@ -1,394 +0,0 @@ -"""Unit tests for Getting Started documentation note integrity.""" - -import subprocess -from pathlib import Path - -import pytest - - -def extract_tab_content(md_content: str, tab_title: str) -> str | None: - """Extracts the content of a specific tab section from Markdown. - - A tab section starts with a line containing `=== "tab_title"`. - All subsequent lines belonging to this tab are indented (by at least 4 spaces) - or are blank lines. The tab section ends when a non-blank line with no leading - indentation (spaces or tabs) is encountered. - """ - lines = md_content.splitlines() - in_tab = False - tab_lines = [] - for line in lines: - if not in_tab: - if line.strip().startswith(f'=== "{tab_title}"'): - in_tab = True - continue - else: - # End of tab is a non-empty line starting with no spaces/tabs (e.g. another tab or heading) - if line.strip() != "" and not (line.startswith(" ") or line.startswith("\t")): - break - tab_lines.append(line) - - if not in_tab: - return None - return "\n".join(tab_lines) - - -def find_troubleshooting_note(tab_content: str) -> str | None: - """Finds the line containing the troubleshooting note in the extracted tab content. - - We locate the note by looking for the core text phrase 'HTTP 405 error'. - This allows us to find the note even if the emoji or other properties are incorrect - or missing, so that we can perform assertions on those properties. - """ - for line in tab_content.splitlines(): - stripped = line.strip() - if "HTTP 405 error" in stripped: - return stripped - return None - - -def validate_troubleshooting_note(md_content: str) -> None: - """Validates the markdown content of getting-started.md for the troubleshooting note. - - Raises AssertionError if any of the documentation integrity rules are violated. - """ - # 1. Extract content of === "forge-poller (recommended)" - tab_content = extract_tab_content(md_content, "forge-poller (recommended)") - assert tab_content is not None, ( - "Section === 'forge-poller (recommended)' was not found in the documentation." - ) - - # 2. Find the troubleshooting note in the extracted tab content - note = find_troubleshooting_note(tab_content) - assert note is not None, ( - "The troubleshooting note was not found inside the 'forge-poller (recommended)' tab section." - ) - - # 3. Verify it starts with the 🐱 Unicode cat emoji - assert note.startswith("🐱"), ( - "The troubleshooting note must start with a 🐱 Unicode cat emoji (BR-003)." - ) - - # 4. Verify it is exactly one sentence in length (no intermediate periods) - text_content = note[1:].strip() - assert text_content.endswith("."), "The troubleshooting note must end with a period." - assert "." not in text_content[:-1], ( - "The troubleshooting note must be exactly one sentence in length (no intermediate sentence-ending periods) (BR-001)." - ) - - -def test_getting_started_exists() -> None: - """Verify that the docs/getting-started.md file exists.""" - doc_path = Path(__file__).parents[2] / "docs" / "getting-started.md" - assert doc_path.exists(), "docs/getting-started.md file does not exist" - assert doc_path.is_file(), "docs/getting-started.md is not a file" - - -def test_getting_started_note_integrity() -> None: - """Verify the integrity of the troubleshooting note in the actual docs/getting-started.md file.""" - doc_path = Path(__file__).parents[2] / "docs" / "getting-started.md" - md_content = doc_path.read_text(encoding="utf-8") - validate_troubleshooting_note(md_content) - - -def test_validation_failure_missing_tab() -> None: - """Verify that validation fails if the recommended tab section is missing.""" - bad_md = """ -For local development you have two options: - -=== "forge-poller (not recommended)" - - Register the ticket you're testing: - - 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). -""" - with pytest.raises( - AssertionError, match="Section === 'forge-poller \\(recommended\\)' was not found" - ): - validate_troubleshooting_note(bad_md) - - -def test_validation_failure_missing_note() -> None: - """Verify that validation fails if the troubleshooting note is missing inside the recommended tab.""" - bad_md = """ -For local development you have two options: - -=== "forge-poller (recommended)" - - Register the ticket you're testing: -""" - with pytest.raises( - AssertionError, - match="The troubleshooting note was not found inside the 'forge-poller \\(recommended\\)'", - ): - validate_troubleshooting_note(bad_md) - - -def test_validation_failure_missing_emoji() -> None: - """Verify that validation fails if the 🐱 Unicode cat emoji is missing from the troubleshooting note.""" - bad_md = """ -For local development you have two options: - -=== "forge-poller (recommended)" - - Register the ticket you're testing: - - If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). -""" - with pytest.raises( - AssertionError, match="The troubleshooting note must start with a 🐱 Unicode cat emoji" - ): - validate_troubleshooting_note(bad_md) - - -def test_validation_failure_not_one_sentence() -> None: - """Verify that validation fails if the troubleshooting note contains multiple sentences.""" - bad_md = """ -For local development you have two options: - -=== "forge-poller (recommended)" - - Register the ticket you're testing: - - 🐱 If you receive an HTTP 405 error. You likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). -""" - with pytest.raises( - AssertionError, match="The troubleshooting note must be exactly one sentence in length" - ): - validate_troubleshooting_note(bad_md) - - -def test_validation_failure_placed_incorrectly() -> None: - """Verify that validation fails if the troubleshooting note is placed in the wrong section.""" - bad_md = """ -For local development you have two options: - -=== "forge-poller (recommended)" - - Register the ticket you're testing: - -=== "ngrok (tunnel)" - - 🐱 If you receive an HTTP 405 error, you likely sent the request to the Forge gateway port (8000) instead of the forge-poller port (8001). -""" - with pytest.raises( - AssertionError, - match="The troubleshooting note was not found inside the 'forge-poller \\(recommended\\)'", - ): - validate_troubleshooting_note(bad_md) - - -def is_path_in_allowed_directories(path_str: str) -> bool: - """Check if the given relative path is within the allowed directories (docs/ or tests/).""" - # Normalize path separator to forward slash - path_str = path_str.replace("\\", "/") - return path_str.startswith("docs/") or path_str.startswith("tests/") - - -def parse_git_status_line(line: str) -> list[str]: - """Parse a single line from 'git status --porcelain' and return the file path(s) involved.""" - if len(line) < 4: - return [] - # In porcelain format, the status is the first 2 characters, followed by a space. - status = line[:2] - path_part = line[3:] - - # Handle rename/copy which has " -> " separator - if ("R" in status or "C" in status) and " -> " in path_part: - parts = path_part.split(" -> ") - return [p.strip("\"' ") for p in parts] - - return [path_part.strip("\"' ")] - - -def check_exclusive_documentation_scope(git_status_output: str) -> None: - """Verifies that all modified or staged files are restricted to docs/ and tests/ directories. - - Raises AssertionError if any file outside of docs/ and tests/ directories is modified or staged. - """ - violating_files = [] - for line in git_status_output.splitlines(): - if not line.strip(): - continue - paths = parse_git_status_line(line) - for path in paths: - if not is_path_in_allowed_directories(path): - violating_files.append((line, path)) - - if violating_files: - details = "\n".join( - f"- Line: '{line.strip()}' (Parsed Path: '{path}')" for line, path in violating_files - ) - raise AssertionError( - f"Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories.\n" - f"The following violating files were modified or staged:\n{details}" - ) - - -def test_exclusive_documentation_scope() -> None: - """Verify that no files outside of docs/ and tests/ directories have been modified or staged (BR-002).""" - # Execute git status --porcelain - result = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - text=True, - check=True, - ) - - # Verify using our validator - check_exclusive_documentation_scope(result.stdout) - - -def test_exclusive_scope_success_empty() -> None: - """Verify that empty git status output passes the scope validation.""" - check_exclusive_documentation_scope("") - - -def test_exclusive_scope_success_docs_and_tests() -> None: - """Verify that modifications restricted to docs/ and tests/ pass the scope validation.""" - status_output = ( - " M docs/getting-started.md\n" - "A tests/unit/test_documentation.py\n" - "?? tests/unit/new_test_file.py\n" - ) - check_exclusive_documentation_scope(status_output) - - -def test_exclusive_scope_failure_src() -> None: - """Verify that modifications in src/ cause scope validation failure.""" - status_output = " M docs/getting-started.md\n M src/forge/main.py\n" - with pytest.raises( - AssertionError, - match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", - ) as exc_info: - check_exclusive_documentation_scope(status_output) - - assert "src/forge/main.py" in str(exc_info.value) - - -def test_exclusive_scope_failure_config() -> None: - """Verify that modifications in root configuration files cause scope validation failure.""" - status_output = " M pyproject.toml\n" - with pytest.raises( - AssertionError, - match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", - ) as exc_info: - check_exclusive_documentation_scope(status_output) - - assert "pyproject.toml" in str(exc_info.value) - - -def test_exclusive_scope_rename_success() -> None: - """Verify that renames restricted to docs/ pass the scope validation.""" - status_output = "R docs/old-doc.md -> docs/new-doc.md\n" - check_exclusive_documentation_scope(status_output) - - -def test_exclusive_scope_rename_failure() -> None: - """Verify that renames moving files outside of docs/ and tests/ cause scope validation failure.""" - status_output = "R docs/getting-started.md -> src/getting-started.md\n" - with pytest.raises( - AssertionError, - match="Business Rule BR-002 Violation: Changes detected outside of docs/ and tests/ directories", - ) as exc_info: - check_exclusive_documentation_scope(status_output) - - assert "src/getting-started.md" in str(exc_info.value) - - -def test_exclusive_scope_rename_with_different_status_codes() -> None: - """Verify that renames with different status codes (e.g., RM, R) are parsed correctly.""" - # Renamed in worktree with space prefix " R" - status_output_1 = " R docs/old-doc.md -> docs/new-doc.md\n" - check_exclusive_documentation_scope(status_output_1) - - # Renamed and modified "RM" - status_output_2 = "RM docs/old-doc.md -> docs/new-doc.md\n" - check_exclusive_documentation_scope(status_output_2) - - # Rename failure with different status code - status_output_3 = " R docs/getting-started.md -> src/getting-started.md\n" - with pytest.raises(AssertionError): - check_exclusive_documentation_scope(status_output_3) - - -def test_zensical_build_clean_compilation() -> None: - """Verify that the documentation site compiles cleanly using the Zensical build pipeline.""" - # Run the compilation command: uv run --extra docs zensical build - root_dir = Path(__file__).parents[2] - uv_path = root_dir / ".venv" / "bin" / "uv" - - # Try using the absolute path of uv in .venv first if it exists, otherwise fall back to system 'uv' - if uv_path.exists(): - cmd = [str(uv_path), "run", "--extra", "docs", "zensical", "build"] - else: - cmd = ["uv", "run", "--extra", "docs", "zensical", "build"] - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - ) - except FileNotFoundError: - pytest.fail("Neither .venv/bin/uv nor system 'uv' was found on the path.") - - # 1. Running uv run --extra docs zensical build returns exit code 0 - assert result.returncode == 0, ( - f"Zensical build failed with exit code {result.returncode}.\n" - f"stdout: {result.stdout}\n" - f"stderr: {result.stderr}" - ) - - # 2. No compilation warnings or syntax errors are reported during the build. - stderr_lines = result.stderr.splitlines() - filtered_stderr = [] - for line in stderr_lines: - line_lower = line.lower() - # Exclude typical uv setup / performance / cache / notice messages - if "failed to hardlink" in line_lower: - continue - if "if the cache and target" in line_lower: - continue - if "if this is intentional, set" in line_lower or "suppress this warning" in line_lower: - continue - if "notice: a new release of pip" in line_lower: - continue - # Also exclude virtualenv / downloading output from uv - if line_lower.startswith("downloading ") or line_lower.startswith(" downloaded "): - continue - if "uninstalled" in line_lower and "package" in line_lower: - continue - if "installed" in line_lower and "package" in line_lower: - continue - if "built forge" in line_lower or "building forge" in line_lower: - continue - filtered_stderr.append(line) - - filtered_stderr_str = "\n".join(filtered_stderr) - - assert "error" not in filtered_stderr_str.lower(), ( - f"Zensical build reported errors:\n{filtered_stderr_str}" - ) - assert "warning" not in filtered_stderr_str.lower(), ( - f"Zensical build reported warnings:\n{filtered_stderr_str}" - ) - - # 3. The built static documentation site is correctly generated under the configured output directory. - root_dir = Path(__file__).parents[2] - site_dir = root_dir / "site" - assert site_dir.exists() and site_dir.is_dir(), ( - f"Built documentation directory '{site_dir}' does not exist or is not a directory." - ) - - getting_started_index = site_dir / "getting-started" / "index.html" - assert getting_started_index.exists() and getting_started_index.is_file(), ( - f"Built documentation for getting-started is missing: '{getting_started_index}'" - ) - - # Verify that the cat troubleshooting note is in getting_started_index - content = getting_started_index.read_text(encoding="utf-8") - assert "🐱" in content, "The Unicode cat emoji '🐱' was not found in the compiled HTML." - assert "HTTP 405 error" in content, ( - "The troubleshooting note text was not found in the compiled HTML." - )