From ea39bacc7c70f1b3d8e8590070ccefb56d96a41f Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 14 May 2026 20:33:06 -0700 Subject: [PATCH] feat: seedgo audit in CI + windows_compat test skipif enforcement --- .github/workflows/ci.yml | 46 +++ .../aipass_standards/windows_compat_check.py | 137 ++++++- .../seedgo/tests/test_windows_compat.py | 343 ++++++++++++++++++ 3 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 src/aipass/seedgo/tests/test_windows_compat.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eac18f0..792e03ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,52 @@ jobs: pip install -e ".[dev]" - run: coverage run -m pytest -v --tb=short --rootdir=. + standards: + name: seedgo-audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run seedgo standards audit + run: python -c " + import sys + from pathlib import Path + from aipass.seedgo.apps.handlers.audit.branch_audit import audit_branch + from aipass.seedgo.apps.handlers.bypass.bypass_handler import load_bypass_rules + + src = Path('src/aipass') + pack = src / 'seedgo/apps/handlers/aipass_standards' + + branches = [] + for d in sorted(src.iterdir()): + if d.is_dir() and (d / 'apps').is_dir(): + entry = d / 'apps' / f'{d.name}.py' + branches.append({'name': d.name, 'path': str(d), + 'entry_file': str(entry) if entry.exists() else ''}) + + failed = [] + for branch in branches: + bypass_rules = load_bypass_rules(branch['path']) + result = audit_branch(branch, bypass_rules, pack_path=pack) + avg = result.get('average', 0) + print(f\" {branch['name']:>12}: {avg:.0f}%\") + if avg < 80: + failed.append((branch['name'], avg)) + + if failed: + print(f\"\nFAILED: {len(failed)} branch(es) below 80%\") + for name, score in failed: + print(f\" {name}: {score:.0f}%\") + sys.exit(1) + else: + print(f\"\nAll {len(branches)} branches pass (>=80%)\") + " + coverage: name: coverage needs: [test] diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/windows_compat_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/windows_compat_check.py index d44805f2..8021a2cd 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/windows_compat_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/windows_compat_check.py @@ -1,26 +1,12 @@ # =================== AIPass ==================== # Name: windows_compat_check.py # Description: Windows Compatibility Standards Checker Handler -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-05-10 -# Modified: 2026-05-10 +# Modified: 2026-05-14 # ============================================= -""" -Windows Compatibility Standards Checker Handler - -Detects POSIX-only patterns that will crash or behave incorrectly on -Windows. Scans for: - - 1. Unguarded POSIX-only imports (fcntl, pwd, grp, termios, resource) - 2. Unguarded POSIX-only constants (os.WNOHANG, signal.SIGPIPE) - 3. Unguarded POSIX-only calls (os.fork, os.setpgid, os.killpg, os.waitpid) - 4. os.kill() without try/except catching OSError - -Detection uses AST parsing to identify violations and check whether they -sit inside a platform guard (if sys.platform / if os.name / try-except -ImportError). -""" +"""Windows Compatibility Standards Checker Handler.""" import ast from pathlib import Path @@ -48,6 +34,15 @@ _GUARDED_EXCEPT_TYPES = frozenset( } ) +_TEST_POSIX_CALLS: dict[tuple[str, str], str] = { + ("os", "chmod"): "os.chmod() — Windows ignores permission bits", + ("os", "symlink"): "os.symlink() — Windows needs privileges", + ("os", "getuid"): "os.getuid() — not available on Windows", + ("os", "getgid"): "os.getgid() — not available on Windows", + ("os", "chown"): "os.chown() — not available on Windows", + ("stat", "S_IMODE"): "stat.S_IMODE() — Unix permission assertion", +} + def _is_platform_guard(node: ast.expr) -> bool: """Return True if the test expression is a sys.platform or os.name comparison.""" @@ -172,6 +167,111 @@ def _find_os_kill_violations(tree: ast.Module, guarded: set[int]) -> list[tuple[ return violations +def _is_test_file(path: Path) -> bool: + return "tests" in path.parts or path.name.startswith("test_") or path.name.endswith("_test.py") + + +def _is_pytest_mark(node: ast.expr) -> bool: + return ( + isinstance(node, ast.Attribute) + and node.attr == "mark" + and isinstance(node.value, ast.Name) + and node.value.id == "pytest" + ) + + +def _references_platform(node: ast.expr) -> bool: + for child in ast.walk(node): + if isinstance(child, ast.Attribute) and isinstance(child.value, ast.Name): + if (child.value.id == "sys" and child.attr == "platform") or ( + child.value.id == "os" and child.attr == "name" + ): + return True + return False + + +def _has_platform_skipif(decorators: list[ast.expr]) -> bool: + for dec in decorators: + if isinstance(dec, ast.Attribute) and dec.attr == "skip" and _is_pytest_mark(dec.value): + return True + if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute): + continue + func = dec.func + if func.attr == "skip" and _is_pytest_mark(func.value): + return True + if func.attr == "skipif" and _is_pytest_mark(func.value): + if any(_references_platform(arg) for arg in dec.args): + return True + return False + + +def _match_posix_call(node: ast.AST) -> str | None: + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + return None + if not isinstance(node.func.value, ast.Name): + return None + key = (node.func.value.id, node.func.attr) + return _TEST_POSIX_CALLS.get(key) + + +def _match_stat_constant(node: ast.AST) -> str | None: + if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): + return None + if node.value.id == "stat" and node.attr.startswith("S_I") and node.attr != "S_IMODE": + return f"stat.{node.attr} — Unix permission constant" + return None + + +def _scan_test_body( + func_node: ast.FunctionDef | ast.AsyncFunctionDef, + guarded: set[int], + violations: list[tuple[int, str]], +) -> None: + seen_lines: set[int] = set() + for node in ast.walk(func_node): + lineno = getattr(node, "lineno", None) + if lineno is None or lineno in guarded or lineno in seen_lines: + continue + + desc = _match_posix_call(node) or _match_stat_constant(node) + if desc: + violations.append((lineno, desc)) + seen_lines.add(lineno) + + +def _collect_unguarded_tests( + parent: ast.Module | ast.ClassDef, + class_skipif: bool = False, +) -> list[ast.FunctionDef | ast.AsyncFunctionDef]: + results: list[ast.FunctionDef | ast.AsyncFunctionDef] = [] + for node in ast.iter_child_nodes(parent): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not node.name.startswith("test_"): + continue + if not class_skipif and not _has_platform_skipif(node.decorator_list): + results.append(node) + return results + + +def _find_test_platform_violations( + tree: ast.Module, + guarded: set[int], +) -> list[tuple[int, str]]: + violations: list[tuple[int, str]] = [] + targets: list[ast.FunctionDef | ast.AsyncFunctionDef] = [] + + targets.extend(_collect_unguarded_tests(tree)) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + targets.extend(_collect_unguarded_tests(node, _has_platform_skipif(node.decorator_list))) + + for func in targets: + _scan_test_body(func, guarded, violations) + + return violations + + def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """Check a Python file for Windows-incompatible patterns.""" path = Path(module_path) @@ -260,6 +360,9 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: all_violations.extend(_find_posix_call_violations(tree, guarded)) all_violations.extend(_find_os_kill_violations(tree, guarded)) + if _is_test_file(path): + all_violations.extend(_find_test_platform_violations(tree, guarded)) + non_bypassed = [ (ln, desc) for ln, desc in all_violations if not is_bypassed(module_path, "windows_compat", ln, bypass_rules) ] diff --git a/src/aipass/seedgo/tests/test_windows_compat.py b/src/aipass/seedgo/tests/test_windows_compat.py new file mode 100644 index 00000000..0949822d --- /dev/null +++ b/src/aipass/seedgo/tests/test_windows_compat.py @@ -0,0 +1,343 @@ +# =================== AIPass ==================== +# Name: test_windows_compat.py +# Description: Tests for windows_compat_check.py +# Version: 1.0.0 +# Created: 2026-05-14 +# Modified: 2026-05-14 +# ============================================= + +"""Tests for windows_compat_check — both POSIX-import detection and test-file skipif enforcement.""" + +import pytest +from unittest.mock import MagicMock + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + import sys + + mock_logger = MagicMock() + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod) + + bypass_pkg = MagicMock() + bypass_ignore = MagicMock() + bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed + + bypass_utils = MagicMock() + bypass_utils.is_bypassed = real_is_bypassed + bypass_pkg.utils = bypass_utils + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", bypass_ignore) + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils) + + for mod_name in ["aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check"]: + monkeypatch.delitem(sys.modules, mod_name, raising=False) + + +# =========================================================================== +# Existing POSIX-import detection +# =========================================================================== + + +def test_clean_file_passes(tmp_path): + f = tmp_path / "clean.py" + f.write_text("import os\nx = os.path.join('a', 'b')\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + assert result["score"] == 100 + + +def test_unguarded_fcntl_import_fails(tmp_path): + f = tmp_path / "bad.py" + f.write_text("import fcntl\nfcntl.flock(0, 0)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "import fcntl" in result["checks"][0]["message"] + + +def test_guarded_fcntl_import_passes(tmp_path): + f = tmp_path / "guarded.py" + f.write_text("import sys\ntry:\n import fcntl\nexcept ImportError:\n fcntl = None\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_platform_guarded_os_kill_passes(tmp_path): + f = tmp_path / "guarded_kill.py" + f.write_text("import os, sys\nif sys.platform != 'win32':\n os.kill(1, 9)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_unguarded_os_kill_fails(tmp_path): + f = tmp_path / "bad_kill.py" + f.write_text("import os\nos.kill(1, 9)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.kill()" in result["checks"][0]["message"] + + +def test_init_file_skipped(tmp_path): + f = tmp_path / "__init__.py" + f.write_text("import fcntl\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + assert result["checks"][0]["message"] == "File skipped (non-target)" + + +def test_posix_only_call_fork_fails(tmp_path): + f = tmp_path / "forker.py" + f.write_text("import os\npid = os.fork()\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.fork()" in result["checks"][0]["message"] + + +def test_posix_constant_wnohang_fails(tmp_path): + f = tmp_path / "waiter.py" + f.write_text("import os\nflags = os.WNOHANG\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.WNOHANG" in result["checks"][0]["message"] + + +# =========================================================================== +# Test-file skipif enforcement (new) +# =========================================================================== + + +def test_unguarded_chmod_in_test_file_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_perms.py" + f.write_text("import os\n\ndef test_permissions():\n os.chmod('file', 0o600)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.chmod()" in result["checks"][0]["message"] + + +def test_skipif_guarded_chmod_passes(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_perms.py" + f.write_text( + "import os, sys, pytest\n\n" + '@pytest.mark.skipif(sys.platform == "win32", reason="no perms")\n' + "def test_permissions():\n" + " os.chmod('file', 0o600)\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_unguarded_stat_imode_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_perms.py" + f.write_text( + "import os, stat\n\n" + "def test_file_mode():\n" + " mode = stat.S_IMODE(os.stat('file').st_mode)\n" + " assert mode == 0o600\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "stat.S_IMODE()" in result["checks"][0]["message"] + + +def test_unguarded_stat_constant_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_perms.py" + f.write_text("import stat\n\ndef test_check_bits():\n expected = stat.S_IRUSR | stat.S_IWUSR\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "stat.S_IRUSR" in result["checks"][0]["message"] + + +def test_unguarded_symlink_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_links.py" + f.write_text("import os\n\ndef test_symlink():\n os.symlink('a', 'b')\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.symlink()" in result["checks"][0]["message"] + + +def test_unguarded_getuid_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_user.py" + f.write_text("import os\n\ndef test_uid():\n uid = os.getuid()\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.getuid()" in result["checks"][0]["message"] + + +def test_class_level_skipif_guards_all_methods(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_perms.py" + f.write_text( + "import os, sys, pytest\n\n" + '@pytest.mark.skipif(sys.platform == "win32", reason="unix only")\n' + "class TestPermissions:\n" + " def test_chmod(self):\n" + " os.chmod('file', 0o600)\n" + " def test_getuid(self):\n" + " os.getuid()\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_method_level_skipif_in_unguarded_class(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_mixed.py" + f.write_text( + "import os, sys, pytest\n\n" + "class TestMixed:\n" + ' @pytest.mark.skipif(sys.platform == "win32", reason="unix")\n' + " def test_guarded(self):\n" + " os.chmod('file', 0o600)\n" + " def test_unguarded(self):\n" + " os.chmod('file', 0o700)\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.chmod()" in result["checks"][0]["message"] + + +def test_pytest_mark_skip_unconditional_passes(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_skipped.py" + f.write_text( + "import os, pytest\n\n@pytest.mark.skip(reason=\"not yet\")\ndef test_perms():\n os.chmod('file', 0o600)\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_non_test_file_ignores_chmod(tmp_path): + f = tmp_path / "handler.py" + f.write_text("import os\n\ndef set_perms():\n os.chmod('file', 0o600)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_inline_platform_guard_in_test_passes(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_guarded_inline.py" + f.write_text( + "import os, sys\n\ndef test_perms():\n if sys.platform != \"win32\":\n os.chmod('file', 0o600)\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_os_name_skipif_passes(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_osname.py" + f.write_text( + "import os, pytest\n\n" + '@pytest.mark.skipif(os.name != "posix", reason="posix only")\n' + "def test_chmod():\n" + " os.chmod('file', 0o600)\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_non_test_function_in_test_file_ignored(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_helpers.py" + f.write_text( + "import os\n\ndef helper_setup():\n os.chmod('file', 0o600)\n\ndef test_clean():\n assert True\n" + ) + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is True + + +def test_chown_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_owner.py" + f.write_text("import os\n\ndef test_ownership():\n os.chown('file', 1000, 1000)\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.chown()" in result["checks"][0]["message"] + + +def test_getgid_in_test_fails(tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + f = tests_dir / "test_gid.py" + f.write_text("import os\n\ndef test_group():\n gid = os.getgid()\n") + from aipass.seedgo.apps.handlers.aipass_standards.windows_compat_check import check_module + + result = check_module(str(f)) + assert result["passed"] is False + assert "os.getgid()" in result["checks"][0]["message"]