Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]

jobs:
lint:
Expand All @@ -16,8 +15,15 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- run: uvx --python ${{ matrix.python-version }} ruff check src/
- run: uvx --python ${{ matrix.python-version }} ruff format --check src/
- run: uvx --python ${{ matrix.python-version }} ruff check src/ tests/ scripts/
- run: uvx --python ${{ matrix.python-version }} ruff format --check src/ tests/ scripts/

guards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run CI guards
run: bash scripts/ci_guards.sh

type-check:
runs-on: ubuntu-latest
Expand All @@ -28,7 +34,34 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libgirepository-2.0-dev libdbus-1-dev pkg-config
- run: uv run ty check src/
- run: uv run ty check src/ tests/ scripts/

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Skip 3.15 (alpha): rpds-py has no wheel and Rust source build fails on cpython-3.15.0a8.
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libgirepository-2.0-dev libdbus-1-dev pkg-config
- name: Run non-kwin pytest (tolerate exit 5 = no tests collected)
# Ignore tests/integration: those tests require a real KWin/Wayland session
# and import kwin_mcp.input which dlopens libei.so.1 (not available on GHA).
run: |
set +e
uv run --python ${{ matrix.python-version }} pytest -m "not kwin" --ignore=tests/integration
rc=$?
set -e
if [ "$rc" -eq 0 ] || [ "$rc" -eq 5 ]; then
exit 0
fi
exit "$rc"

build:
runs-on: ubuntu-latest
Expand All @@ -48,7 +81,7 @@ jobs:

publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: [lint, type-check, build]
needs: [lint, type-check, test, build, guards]
runs-on: ubuntu-latest
environment: pypi
permissions:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/docs-seo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ name: Docs & SEO Review
# when the detected changes do not warrant a documentation review.
on:
pull_request:
branches: [main]
paths:
- "src/kwin_mcp/**"
- "pyproject.toml"
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ See ROADMAP.md. Key modules:
| `src/kwin_mcp/server.py` | tool-registration, code-general | Check tool count, README tool tables, CONTRIBUTING structure, **integrations/*/skills/*/SKILL.md tool list freshness** |
| `src/kwin_mcp/session.py` | session-api | Check README arch diagram, CONTRIBUTING session docs |
| `src/kwin_mcp/core.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure |
| `src/kwin_mcp/dbus_args.py` | code-general | Check concrete numbers, CONTRIBUTING file listing, **integrations/*/skills/*/SKILL.md "30 capabilities" reference** |
| `src/kwin_mcp/window.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure |
| `src/kwin_mcp/accessibility_worker.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure |
| `src/kwin_mcp/*.py` (any) | code-general | Check concrete numbers, CONTRIBUTING file listing, **integrations/*/skills/*/SKILL.md "30 capabilities" reference** |
| `docs/design/*.md` | code-general | Check docs/design architecture notes and related docs-seo guidance |
| `pyproject.toml` | package-metadata | Sync keywords with `.claude/positioning.yml`; check CLAUDE.md keyword tiers; **run `python3 scripts/sync_plugin_version.py` to propagate version to integrations manifests** |
| `CHANGELOG.md` | changelog-update | Sync docs-seo.md positioning; add new search intents |
| `README.md` | readme-update | Sync docs-seo.md positioning; update CLAUDE.md keyword tiers |
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ requires = ["uv_build>=0.10.3,<0.12.0"]
build-backend = "uv_build"

[dependency-groups]
dev = ["ruff>=0.11.0", "ty>=0.0.1"]
dev = ["ruff>=0.11.0", "ty>=0.0.1", "pytest>=8.0", "pytest-asyncio>=0.23"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
asyncio_mode = "auto"
markers = ["kwin: marks tests requiring a real KWin virtual session (deselect with -m 'not kwin')"]

[tool.ruff]
target-version = "py312"
Expand Down
162 changes: 162 additions & 0 deletions scripts/ci_guards.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# CI Guards - enforce backend overhaul invariants.
#
# Each guard prints a clear failure message identifying the rule and
# the offending location(s), then exits non-zero. On a clean tree,
# the script exits 0 silently (per-guard "ok" lines are emitted to
# stderr for visibility in CI logs).
#
# Guards:
# 1. No reachable `print()` in src/kwin_mcp/ (excluding __main__ blocks)
# 2. Every `kwin_wayland` invocation in tests/, scripts/, docs/ has --virtual
# 3. core.py does not import accessibility_worker at module top
# 4. No os.fork or raw multiprocessing.Process( in src/kwin_mcp/

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

fail=0

log_ok() {
echo "[ci_guards] OK: $1" >&2
}

log_fail() {
echo "[ci_guards] FAIL: $1" >&2
fail=1
}

# ---------------------------------------------------------------------------
# Guard 1: No reachable `print()` in MCP server code paths under src/kwin_mcp/.
# Excludes:
# - __main__.py (module entrypoint guard blocks)
# - cli.py (interactive REPL whose contract IS to write to stdout)
# Rationale: any print() reachable from server.py would corrupt the MCP
# stdio JSON-RPC stream.
# ---------------------------------------------------------------------------
guard_1_no_print() {
local matches
matches="$(grep -rn -E '^[[:space:]]*print\(' src/kwin_mcp/ \
--include='*.py' 2>/dev/null \
| grep -v '__main__' \
| grep -v 'src/kwin_mcp/cli.py:' || true)"
if [[ -n "$matches" ]]; then
log_fail "Guard 1 - reachable print() found in MCP server code paths (forbidden, use logging instead):"
echo "$matches" >&2
return
fi
log_ok "Guard 1 - no reachable print() in MCP server code paths"
}

# ---------------------------------------------------------------------------
# Guard 2: Every kwin_wayland reference in tests/, scripts/, docs/ uses
# --virtual on the same line.
# ---------------------------------------------------------------------------
guard_2_kwin_virtual() {
local matches=""
for d in tests scripts docs; do
if [[ -d "$d" ]]; then
local found
# Exclusions (non-launch references):
# - this guards script itself (documents kwin_wayland)
# - pkill cleanup lines (process kill, not a launch)
# - --version queries (introspection, not a launch)
# - lines whose first non-whitespace token after `path:lineno:` is `echo`
# (documentation strings inside shell scripts)
found="$(grep -rn 'kwin_wayland' "$d" 2>/dev/null \
| grep -v -- '--virtual' \
| grep -v 'scripts/ci_guards.sh' \
| grep -v 'pkill' \
| grep -v -- '--version' \
| grep -Pv '^\S+:\s*echo\s' || true)"
if [[ -n "$found" ]]; then
matches+="$found"$'\n'
fi
fi
done
if [[ -n "$matches" ]]; then
log_fail "Guard 2 - kwin_wayland invocation without --virtual in tests/scripts/docs:"
printf '%s' "$matches" >&2
return
fi
log_ok "Guard 2 - all kwin_wayland references in tests/scripts/docs use --virtual"
}

# ---------------------------------------------------------------------------
# Guard 3: src/kwin_mcp/core.py must not import accessibility_worker at module
# top level. Uses Python AST so conditional/inline imports are tolerated.
# ---------------------------------------------------------------------------
guard_3_no_module_top_worker_import() {
local target="src/kwin_mcp/core.py"
if [[ ! -f "$target" ]]; then
log_ok "Guard 3 - $target absent, skipping"
return
fi
local result
result="$(python3 - "$target" <<'PYEOF'
import ast
import sys

path = sys.argv[1]
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=path)

violations = []
for node in tree.body:
if isinstance(node, ast.Import):
for alias in node.names:
if "accessibility_worker" in alias.name:
violations.append(f"line {node.lineno}: import {alias.name}")
elif isinstance(node, ast.ImportFrom):
mod = node.module or ""
if "accessibility_worker" in mod:
names = ", ".join(a.name for a in node.names)
violations.append(f"line {node.lineno}: from {mod} import {names}")
else:
for alias in node.names:
if "accessibility_worker" in alias.name:
violations.append(
f"line {node.lineno}: from {mod} import {alias.name}"
)

if violations:
print("\n".join(violations))
sys.exit(1)
PYEOF
)" || {
log_fail "Guard 3 - core.py has module-top accessibility_worker import (must be lazy/local):"
echo "$result" >&2
return
}
log_ok "Guard 3 - core.py has no module-top accessibility_worker import"
}

# ---------------------------------------------------------------------------
# Guard 4: No os.fork or raw multiprocessing.Process( in src/kwin_mcp/.
# ---------------------------------------------------------------------------
guard_4_no_fork_or_process() {
local matches
matches="$(grep -rn -E 'os\.fork\b|multiprocessing\.Process\(' src/kwin_mcp/ \
--include='*.py' 2>/dev/null || true)"
if [[ -n "$matches" ]]; then
log_fail "Guard 4 - os.fork or multiprocessing.Process( found in src/kwin_mcp/ (forbidden):"
echo "$matches" >&2
return
fi
log_ok "Guard 4 - no os.fork or multiprocessing.Process( in src/kwin_mcp/"
}

guard_1_no_print
guard_2_kwin_virtual
guard_3_no_module_top_worker_import
guard_4_no_fork_or_process

if [[ "$fail" -ne 0 ]]; then
echo "[ci_guards] One or more guards failed." >&2
exit 1
fi

echo "[ci_guards] All guards passed." >&2
exit 0
Empty file added tests/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

import pytest

from kwin_mcp.session import Session, SessionConfig, SessionInfo

if TYPE_CHECKING:
from collections.abc import Iterator

logger = logging.getLogger(__name__)


@pytest.fixture(scope="session")
def virtual_session() -> Iterator[SessionInfo]:
session = Session()
info = session.start(SessionConfig(socket_name="wayland-test"))
try:
yield info
finally:
logger.info("Stopping virtual KWin session")
session.stop()
14 changes: 14 additions & 0 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import dbus
import dbus.bus
import pytest


@pytest.mark.kwin
def test_virtual_session_reachable(virtual_session) -> None:
assert virtual_session.dbus_address

bus = dbus.bus.BusConnection(virtual_session.dbus_address)
kwin_obj = bus.get_object("org.kde.KWin", "/org/kde/KWin")
dbus.Interface(kwin_obj, "org.freedesktop.DBus.Peer").Ping()
Loading