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/api/tests/test_env_handler.py b/src/aipass/api/tests/test_env_handler.py index f1a76876..999ff75c 100644 --- a/src/aipass/api/tests/test_env_handler.py +++ b/src/aipass/api/tests/test_env_handler.py @@ -17,9 +17,12 @@ from __future__ import annotations import os import stat +import sys from pathlib import Path from unittest.mock import patch +import pytest + from aipass.api.apps.handlers.auth.env import create_env_template @@ -60,6 +63,7 @@ class TestCreateEnvTemplate: assert result is True assert target.read_text(encoding="utf-8") == "EXISTING=content\n" + @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not support Unix file permissions") def test_file_permissions(self, tmp_path: Path) -> None: """Created file has owner-only read/write (0o600).""" target = tmp_path / "secrets" / ".env" @@ -68,6 +72,7 @@ class TestCreateEnvTemplate: file_mode = stat.S_IMODE(os.stat(target).st_mode) assert file_mode == 0o600 + @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not support Unix file permissions") def test_directory_permissions(self, tmp_path: Path) -> None: """Parent directory has owner-only access (0o700).""" secrets_dir = tmp_path / "secrets" diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index c1b08a8a..c9a5b68c 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -43,8 +43,8 @@ Three rules: 3. Local files = source of truth. ``` -drone @git status # changes (all branches) -drone @git diff # diff (all branches) +drone @git status --all # changes (full repo) +drone @git diff --all # diff (full repo) drone @git log # commits (all branches) drone @git branches # remote branches (all branches) drone @git commit "msg" --all # commit all (devpulse only) diff --git a/src/aipass/drone/apps/handlers/git/diff_handler.py b/src/aipass/drone/apps/handlers/git/diff_handler.py index e1e3b9dd..f2339de1 100644 --- a/src/aipass/drone/apps/handlers/git/diff_handler.py +++ b/src/aipass/drone/apps/handlers/git/diff_handler.py @@ -55,13 +55,14 @@ def get_branch_diff(branch_dir: Path, staged: bool = False) -> dict: rel_dir = branch_dir rel_prefix = rel_dir.as_posix() + "/" + show_all = rel_dir == Path(".") filtered_lines: list[str] = [] include_block = False files_changed = 0 for line in result.stdout.splitlines(): if line.startswith("diff --git"): - include_block = rel_prefix in line + include_block = show_all or rel_prefix in line if include_block: files_changed += 1 if include_block: diff --git a/src/aipass/drone/apps/handlers/git/status_handler.py b/src/aipass/drone/apps/handlers/git/status_handler.py index 80d7d903..17a400e2 100644 --- a/src/aipass/drone/apps/handlers/git/status_handler.py +++ b/src/aipass/drone/apps/handlers/git/status_handler.py @@ -62,6 +62,7 @@ def get_branch_status(branch_dir: Path) -> dict: rel_dir = branch_dir rel_prefix = rel_dir.as_posix() + "/" + show_all = rel_dir == Path(".") files = [] for line in result.stdout.splitlines(): @@ -69,7 +70,7 @@ def get_branch_status(branch_dir: Path) -> dict: continue status_code = line[:2].strip() file_path = line[3:] - if file_path.startswith(rel_prefix) or file_path == str(rel_dir): + if show_all or file_path.startswith(rel_prefix) or file_path == str(rel_dir): files.append({"status": status_code, "path": file_path}) total = len(files) diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 528533a3..40afb84f 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -132,7 +132,7 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> if command in _GH_PASSTHROUGH_COMMANDS: return _handle_gh_passthrough(command, args) if command == "status": - return _handle_status() + return _handle_status(args) if command == "diff": return _handle_diff(args) if command == "log": @@ -344,8 +344,11 @@ def _handle_fix(args: list[str], caller: str) -> dict: } -def _handle_status() -> dict: - """Handle the status subcommand (global tier).""" +def _handle_status(args: list[str] | None = None) -> dict: + """Handle the status subcommand (global tier). --all for repo-wide.""" + args = args or [] + show_all = "--all" in args + detected = _detect_branch_dir() if detected is None: return { @@ -355,7 +358,13 @@ def _handle_status() -> dict: } _, branch_dir = detected - result = status_handler.get_branch_status(branch_dir) + + if show_all: + repo_root = lock_handler.find_repo_root() + result = status_handler.get_branch_status(repo_root) + result["message"] = f"{result['total']} file(s) changed in repo" + else: + result = status_handler.get_branch_status(branch_dir) lines = [result["message"]] for f in result["files"]: @@ -369,7 +378,7 @@ def _handle_status() -> dict: def _handle_diff(args: list[str]) -> dict: - """Handle the diff subcommand (global tier).""" + """Handle the diff subcommand (global tier). --staged, --all supported.""" detected = _detect_branch_dir() if detected is None: return { @@ -380,7 +389,10 @@ def _handle_diff(args: list[str]) -> dict: _, branch_dir = detected staged = "--staged" in args - result = diff_handler.get_branch_diff(branch_dir, staged=staged) + show_all = "--all" in args + + target_dir = lock_handler.find_repo_root() if show_all else branch_dir + result = diff_handler.get_branch_diff(target_dir, staged=staged) return { "stdout": result["diff"] if result["diff"] else result["message"], @@ -532,12 +544,13 @@ def get_help(command: str | None = None) -> str: if command == "pr": return "git pr — DEPRECATED. Agent PRs are no longer supported. Devpulse handles git.\n" if command == "status": - return "git status — Show git status filtered to your branch directory [global]\n" + return "git status [--all] — Show git status filtered to your branch (--all for repo-wide) [global]\n" if command == "diff": return ( - "git diff [--staged] — Show git diff filtered to your branch directory [global]\n" + "git diff [--staged] [--all] — Show git diff filtered to your branch [global]\n" " Options:\n" " --staged Show staged changes only.\n" + " --all Show all repo changes (not just your branch).\n" ) if command == "log": return "git log [count] — Show recent git log entries (default: 10) [global]\n" 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"]