Skip to content

Commit c7e2e6f

Browse files
HumanBean17claude
andauthored
feat(platform): declare Windows + macOS support (classifiers, CI matrix, README) (#371)
* feat(platform): declare Windows + macOS support (classifiers, CI matrix, README) Audit confirmed the tool already runs cross-platform — every native dependency ships win_amd64 wheels (cocoindex, ladybug/kuzu, lancedb) and the Python path is portable (pathlib, copy-based install, list-arg subprocess, RLIMIT_NOFILE no-op on Windows) — but nothing declared or verified it. This closes the gap. - pyproject.toml: add Operating System classifiers for Linux/macOS/Windows - .github/workflows/test.yml: 3-OS matrix (ubuntu/macos/windows-latest). Ubuntu remains the required merge gate; macOS + Windows run on every code-change PR but are continue-on-error until each is observed green across several merges (the fast suite exercises the native kuzu/ladybug graph layer, never before run on these OSes). fail-fast: false. - README.md: portable smoke-test paths (/tmp/bank-chat-index -> tmp/bank-chat-index, gitignored + works in any shell/OS), a Linux/macOS/ Windows line in Install, and a Git Bash/WSL note for the POSIX snippets. No .py files modified; runtime behavior is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * fix(windows): guard resource import + close ladybug Database handles The Windows CI leg added in #371 surfaced two real product bugs that left the tool non-functional on Windows. 1. _fdlimit.py imported `resource` at module scope. `resource` is Unix-only, so the import raised ModuleNotFoundError on Windows — and because both cli.py and server.py import raise_fd_limit, the entire CLI and MCP server failed to start. Guard the import (try/except -> None); raise_fd_limit already no-ops when RLIMIT_NOFILE is absent. test_fd_limit is skipped on win32 (it asserts Unix rlimit behaviour). 2. build_ast_graph.py never called db.close(): all 8 cleanup paths in incremental_rebuild + write_ladybug closed the Connection but leaked the Database handle. Windows uses mandatory file locking, so the leaked handle holds the .lbug lock and the next open fails with "Error 33: another process has locked a portion of the file." This broke real init/increment on Windows, not just tests. Add db.close() after every conn.close(); same leak fixed in 4 tests that opened their own handle. Verified locally: 53 affected tests pass on macOS (serial) — no regression. Windows is the CI matrix's job. Co-Authored-By: Claude <noreply@anthropic.com> * fix(windows): atomic config write, graceful erase EOF, cross-platform path tests Second Windows run (after the resource/db.close fixes) failed 12 — all resolved: Product: - installer.py: os.rename -> os.replace for the atomic config write. On Windows os.rename raises WinError 183 when the target already exists, so install/update broke whenever the config file was present (the normal case). os.replace is atomic on both platforms. - cli.py erase: catch EOFError from input() and treat it as a non-interactive refusal. The Windows NUL device reports isatty()==True (it's a character device), so the non-TTY guard is bypassed and input() crashed with an EOF traceback; now it prints the refusal and exits 2. Tests (Unix path assumptions — the suite had never run on Windows before): - test_incremental_graph: pair db.close() with conn.close() in the one remaining orchestrator test (still leaking the Database handle -> Error 33). - test_config / test_java_codebase_rag_cli: set USERPROFILE alongside HOME (Path.home()/expanduser read %USERPROFILE% on Windows), compare paths via Path equality instead of raw strings (forward/back-slash mismatch), and resolve-both-sides for the drive-relative YAML source_root assertions. Verified locally: 122 affected tests pass on macOS (serial). Windows re-run pending. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1691db commit c7e2e6f

13 files changed

Lines changed: 85 additions & 18 deletions

.github/workflows/test.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,20 @@ on:
77

88
jobs:
99
test:
10-
name: test
11-
runs-on: ubuntu-latest
10+
name: test (${{ matrix.os }})
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
# Ubuntu is the required gate (blocks merges). macOS + Windows run on
15+
# every code-change PR to surface platform regressions, but stay
16+
# non-blocking (continue-on-error below) until each has been green
17+
# across several merges — the fast test suite exercises the native
18+
# kuzu/ladybug graph layer, which had never been run on these OSes
19+
# before this matrix. Promote an OS to a hard gate by dropping it from
20+
# the continue-on-error expression once it is reliably green.
21+
os: [ubuntu-latest, macos-latest, windows-latest]
22+
continue-on-error: ${{ matrix.os != 'ubuntu-latest' }}
23+
runs-on: ${{ matrix.os }}
1224
steps:
1325
- uses: actions/checkout@v4
1426
with:

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The rest of this README is the install, walkthrough, and tool cheat sheet for pu
4141
pip install java-codebase-rag
4242
```
4343

44-
Python **3.11+** required. After install, `java-codebase-rag --help` should print the CLI groups.
44+
Python **3.11+** required, on **Linux, macOS, and Windows** — every native dependency (LanceDB, LadybugDB/kuzu, CocoIndex) ships a wheel for each platform. After install, `java-codebase-rag --help` should print the CLI groups.
4545
The package includes the CocoIndex lifecycle dependency used by `init`, `increment`, `reprocess`, and `erase`.
4646

4747
### Interactive setup (recommended)
@@ -86,21 +86,23 @@ cd java-codebase-rag
8686

8787
# 2. Build the index (Lance vectors + LadybugDB graph). First run downloads the
8888
# embedding model (~90 MB) and takes ~30-60s on the fixture.
89-
java-codebase-rag init --source-root tests/bank-chat-system --index-dir /tmp/bank-chat-index
89+
java-codebase-rag init --source-root tests/bank-chat-system --index-dir tmp/bank-chat-index
9090

9191
# 3. Inspect what landed (resolved config, edge counts, ontology version)
92-
java-codebase-rag meta --source-root tests/bank-chat-system --index-dir /tmp/bank-chat-index
92+
java-codebase-rag meta --source-root tests/bank-chat-system --index-dir tmp/bank-chat-index
9393
```
9494

95+
> **Windows users:** these smoke-test snippets use POSIX shell syntax (`VAR=value` prefix, `\` line continuations). Run them under **Git Bash** or **WSL**, or skip straight to `java-codebase-rag install`, which wires up MCP registration and configuration without a shell.
96+
9597
Smoke-test the index with two checks (`search_lancedb` ships with the package):
9698

9799
```bash
98100
# Vector search — proves the LanceDB side works
99-
JAVA_CODEBASE_RAG_INDEX_DIR=/tmp/bank-chat-index \
101+
JAVA_CODEBASE_RAG_INDEX_DIR=tmp/bank-chat-index \
100102
python -m search_lancedb "chat ingress controller" --table java --limit 3
101103

102104
# Vector + graph expansion — proves LadybugDB is wired in
103-
JAVA_CODEBASE_RAG_INDEX_DIR=/tmp/bank-chat-index \
105+
JAVA_CODEBASE_RAG_INDEX_DIR=tmp/bank-chat-index \
104106
python -m search_lancedb "chat ingress controller" --table java --limit 3 \
105107
--graph-expand --expand-depth 2
106108
```

build_ast_graph.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3816,13 +3816,15 @@ def incremental_rebuild(
38163816
if verbose:
38173817
_verbose_stderr_line(f"[increment] ontology version {version} < 17; falling back to full rebuild")
38183818
conn.close()
3819+
db.close()
38193820
del conn, db
38203821
return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
38213822
except Exception as e:
38223823
if verbose:
38233824
_verbose_stderr_line(f"[increment] failed to read ontology version: {e}; falling back to full rebuild")
38243825
try:
38253826
conn.close()
3827+
db.close()
38263828
except Exception:
38273829
pass
38283830
del conn, db
@@ -3841,6 +3843,7 @@ def incremental_rebuild(
38413843
if verbose:
38423844
_verbose_stderr_line("[increment] no changes detected; no-op")
38433845
conn.close()
3846+
db.close()
38443847
return IncrementalResult(
38453848
mode="incremental",
38463849
files_changed=0,
@@ -3859,6 +3862,7 @@ def incremental_rebuild(
38593862
if verbose:
38603863
_verbose_stderr_line("[increment] crash marker exists; falling back to full rebuild")
38613864
conn.close()
3865+
db.close()
38623866
crash_marker_path.unlink(missing_ok=True)
38633867
return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
38643868

@@ -3893,6 +3897,7 @@ def incremental_rebuild(
38933897
if verbose:
38943898
_verbose_stderr_line(f"[increment] dependent expansion cap ({expansion_cap}) exceeded ({len(scope_files)} files); falling back to full rebuild")
38953899
conn.close()
3900+
db.close()
38963901
crash_marker_path.unlink(missing_ok=True)
38973902
return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
38983903

@@ -3977,6 +3982,7 @@ def incremental_rebuild(
39773982
crash_marker_path.unlink(missing_ok=True)
39783983

39793984
conn.close()
3985+
db.close()
39803986

39813987
elapsed = time.time() - t_start
39823988
if verbose:
@@ -3996,6 +4002,7 @@ def incremental_rebuild(
39964002
if verbose:
39974003
_verbose_stderr_line(f"[increment] error during incremental rebuild: {e}; falling back to full rebuild")
39984004
conn.close()
4005+
db.close()
39994006
crash_marker_path.unlink(missing_ok=True)
40004007
return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
40014008

@@ -4200,6 +4207,7 @@ def write_ladybug(
42004207
_verbose_stderr_line(f"[graph] writing · routes/exposes written in {time.time() - t2:.2f}s")
42014208
_write_meta(conn, tables, source_root)
42024209
conn.close()
4210+
db.close()
42034211
_init_hash_tracker(source_root, db_path)
42044212

42054213

java_codebase_rag/_fdlimit.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@
2222

2323
from __future__ import annotations
2424

25-
import resource
25+
try:
26+
# Unix-only: the ``resource`` module does not exist on Windows. Importing it
27+
# unconditionally at module scope crashes on Windows, which (because
28+
# ``cli.py`` and ``server.py`` both import ``raise_fd_limit``) made the
29+
# entire CLI and MCP server fail to start there. Guard the import so the
30+
# module loads everywhere; the function below no-ops when it's absent.
31+
import resource
32+
except ImportError: # pragma: no cover - Windows lacks the resource module
33+
resource = None # type: ignore[assignment]
2634

2735
# Safe ceiling well above LanceDB's appetite, comfortably below macOS libc
2836
# quirks. The hard limit caps it further if lower (locked-down servers).
@@ -35,7 +43,7 @@ def raise_fd_limit(cap: int = _DEFAULT_CAP) -> None:
3543
Best-effort and silent: never raises. No-op where ``RLIMIT_NOFILE`` is
3644
unsupported (Windows) or where the soft limit already meets ``min(hard, cap)``.
3745
"""
38-
if not hasattr(resource, "RLIMIT_NOFILE"):
46+
if resource is None or not hasattr(resource, "RLIMIT_NOFILE"):
3947
return
4048
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
4149
target = min(hard, cap)

java_codebase_rag/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,17 @@ def _cmd_erase(args: argparse.Namespace) -> int:
632632
file=sys.stderr,
633633
)
634634
return 2
635-
ans = input("Delete these paths? [y/N]: ").strip().lower()
635+
try:
636+
ans = input("Delete these paths? [y/N]: ").strip().lower()
637+
except EOFError:
638+
# Non-interactive stdin that nonetheless reported isatty() == True
639+
# (the Windows NUL device is a character device, so isatty() lies).
640+
# Treat it as a refusal instead of crashing with an EOF traceback.
641+
print(
642+
"java-codebase-rag erase: non-interactive stdin; pass --yes to confirm.",
643+
file=sys.stderr,
644+
)
645+
return 2
636646
if ans not in ("y", "yes"):
637647
print("Aborted.", file=sys.stderr)
638648
return 2

java_codebase_rag/installer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -
536536
tmp_name = tmp.name
537537

538538
# Atomic rename
539-
os.rename(tmp_name, config_path)
539+
os.replace(tmp_name, config_path)
540540
return True
541541
except (IOError, OSError) as e:
542542
if tmp_name:
@@ -1258,7 +1258,7 @@ def _refresh_mcp_config(
12581258
tmp_name = tmp.name
12591259

12601260
# Atomic rename
1261-
os.rename(tmp_name, config_path)
1261+
os.replace(tmp_name, config_path)
12621262
print(f"Updated MCP config at {config_path}")
12631263
return ArtifactResult(path=config_path, success=True, error=None)
12641264

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ classifiers = [
2121
"Programming Language :: Python :: 3.12",
2222
"Programming Language :: Python :: 3.13",
2323
"Topic :: Software Development :: Libraries",
24+
"Operating System :: POSIX :: Linux",
25+
"Operating System :: MacOS :: MacOS X",
26+
"Operating System :: Microsoft :: Windows",
2427
]
2528
dependencies = [
2629
"cocoindex[lancedb]>=1.0.7,<2",

tests/test_config.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def test_discover_project_root_ignores_stray_index_dir_at_home(self, tmp_path, m
151151
(stray_idx / "code_graph.lbug").write_bytes(b"\x00" * 16)
152152

153153
monkeypatch.setenv("HOME", str(fake_home))
154+
monkeypatch.setenv("USERPROFILE", str(fake_home)) # Windows: Path.home() uses %USERPROFILE%
154155

155156
result = discover_project_root(project_dir)
156157
assert result is None, "stray ~/.java-codebase-rag/ must not anchor at $HOME (#357)"
@@ -212,9 +213,11 @@ def test_source_root_from_yaml_absolute(self, tmp_path, monkeypatch):
212213
# Change cwd to tmp_path so walk-up finds this config
213214
monkeypatch.chdir(tmp_path)
214215

215-
# source_root=None triggers walk-up discovery + YAML parsing
216+
# source_root=None triggers walk-up discovery + YAML parsing.
217+
# .resolve() on both sides normalises drive-relative anchoring:
218+
# Windows sees "/some/absolute/path" as C:/some/absolute/path.
216219
result = resolve_operator_config(source_root=None)
217-
assert result.source_root == Path(absolute_path)
220+
assert Path(result.source_root).resolve() == Path(absolute_path).resolve()
218221

219222

220223
class TestIndexDirRelativeToConfigDir:
@@ -297,8 +300,9 @@ def test_source_root_precedence_yaml_over_discovery(self, tmp_path, monkeypatch)
297300

298301
# source_root=None triggers walk-up discovery
299302
result = resolve_operator_config(source_root=None)
300-
# YAML should override the discovered config dir
301-
assert result.source_root == Path("/yaml/root")
303+
# YAML should override the discovered config dir. .resolve() normalises
304+
# drive-relative anchoring on Windows ("/yaml/root" -> C:/yaml/root).
305+
assert Path(result.source_root).resolve() == Path("/yaml/root").resolve()
302306

303307
def test_source_root_precedence_env_over_yaml(self, tmp_path, monkeypatch):
304308
"""env var wins over YAML source_root."""
@@ -449,6 +453,7 @@ def test_tilde_expansion_preserved(self, monkeypatch):
449453
from java_codebase_rag.config import maybe_expand_embedding_model_path
450454

451455
monkeypatch.setenv("HOME", "/home/user")
456+
monkeypatch.setenv("USERPROFILE", "/home/user") # Windows expanduser uses %USERPROFILE%
452457
assert maybe_expand_embedding_model_path("~/models/minilm") == "/home/user/models/minilm"
453458

454459
def test_yaml_base_resolves_relative(self, tmp_path):

tests/test_cross_service_resolution_flag.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def test_meta_resolution_null_for_old_graphs(tmp_path: Path) -> None:
218218
},
219219
)
220220
conn.close()
221+
db.close()
221222
LadybugGraph._instance = None
222223
LadybugGraph._instance_path = None
223224
assert LadybugGraph(str(db_path)).meta()["cross_service_resolution"] is None

tests/test_fd_limit.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,20 @@
1111

1212
from __future__ import annotations
1313

14+
import sys
15+
16+
import pytest
17+
1418
from java_codebase_rag import _fdlimit
1519

20+
# These tests exercise the Unix-only ``resource.RLIMIT_NOFILE`` raising path.
21+
# ``raise_fd_limit`` no-ops on Windows (where the ``resource`` module is absent),
22+
# so there is nothing to assert there.
23+
pytestmark = pytest.mark.skipif(
24+
sys.platform.startswith("win"),
25+
reason="resource.RLIMIT_NOFILE is Unix-only; raise_fd_limit no-ops on Windows",
26+
)
27+
1628

1729
def test_raises_soft_limit_up_to_cap(monkeypatch):
1830
"""When soft < min(hard, cap), raise soft to the target and keep hard."""

0 commit comments

Comments
 (0)