Skip to content
Merged
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
25 changes: 24 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ on:
pull_request:

jobs:
lint:
name: Lint (Python)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"

- name: Install ruff
run: pip install ruff

- name: Lint Python (undefined names, syntax errors)
working-directory: libs/openant-core
run: ruff check .

python-tests:
name: Python tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -51,7 +70,7 @@ jobs:

- name: Run Python and parser tests
working-directory: libs/openant-core
run: python -m pytest tests/test_token_tracker.py tests/test_parser_adapter.py tests/test_python_parser.py tests/test_js_parser.py -v
run: python -m pytest tests/ -v

go-tests:
name: Go build + integration (${{ matrix.os }})
Expand Down Expand Up @@ -93,6 +112,10 @@ jobs:
working-directory: apps/openant-cli
run: go vet ./...

- name: Run Go unit tests
working-directory: apps/openant-cli
run: go test ./... -v

- name: Build (Linux/macOS)
if: runner.os != 'Windows'
working-directory: apps/openant-cli
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ __pycache__/
node_modules/
apps/openant-cli/bin/
libs/openant-core/parsers/go/go_parser/go_parser
# docs/
_docs/
91 changes: 91 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,97 @@

All notable changes to OpenAnt are documented in this file.

## [2026-05-10] — Windows compatibility & CI hardening

### Fixed

- **JavaScript parser no longer returns zero functions on Windows.**
`path.relative()` and `path.resolve()` produce backslash-separated
paths there, and ts-morph treats `\` as an escape character when
matching paths it has already added — the analyzer silently emitted
an empty result. The TypeScript analyzer now normalises every path
it hands to ts-morph (and every value stored as a `functionId`
component) to forward slashes via a `toPosixPath()` helper. A
static-scanner test in `libs/openant-core/tests/test_windows_path_handling.py`
enforces the contract on every commit.
- **`--files-from` no longer drops every path on Windows.** File lists
written with CRLF line endings used to leave a trailing `\r` on each
entry, which `addSourceFileAtPath` then failed to resolve. The
TypeScript analyzer now splits on `/\r?\n/` and trims each line.
- **Pipeline status output no longer crashes on cp1252 consoles.**
`parsers/{javascript,go}/test_pipeline.py` previously printed
`✓ ✗ →` directly, which raised `UnicodeEncodeError` on the Windows
default code page. Both pipelines now probe `sys.stdout.encoding` at
import time and fall back to ASCII (`OK` / `FAIL` / `->`) only when
the terminal can't encode the Unicode glyphs — UTF-8 terminals keep
the prettier output.
- **`'charmap' codec can't decode byte ...` errors on Windows.** Bare
`open()` calls and `subprocess.run(..., text=True)` invocations
across `libs/openant-core/` defaulted to the system locale encoding
(cp1252 on Windows), crashing on any source code containing non-ASCII
characters (curly quotes U+2019, accented characters, CJK). All ~190
call sites now go through new helpers in
`libs/openant-core/utilities/file_io.py` (`open_utf8`, `read_json`,
`write_json`, `run_utf8`) that pin UTF-8 explicitly. Four regression
scanners in `tests/test_file_io.py` prevent reintroduction by failing
CI on any new bare `open(`, `.read_text(`/`.write_text(`, `.open(`,
or `subprocess.run(..., text=True)` call without an explicit
`encoding=`.
- **Token tracker NameError on resume.** `core/analyzer.py` called
`tracker.add_prior_usage(...)` without `tracker` being defined in the
surrounding `run_analysis()` function. The path was reached only when
resuming a scan with non-zero prior token usage — a dormant bug
uncovered by the new lint step. Now uses `get_global_tracker()` to
match the existing pattern in the same function.
- **Managed venv path is wrong on Windows.** `venvPython()` in
`apps/openant-cli/internal/python/runtime.go` hard-coded
`bin/python`, which doesn't exist in a Windows venv (the layout there
is `Scripts\python.exe`). The CLI now branches on `runtime.GOOS` and
returns the OS-correct path, so `~/.openant/venv/` is usable on
Windows without setting `OPENANT_PYTHON`. New `runtime_test.go`
covers both layouts.
- **Python parser test pipelines fail when invoked as subprocesses.**
`parsers/{javascript,go}/test_pipeline.py` import from `utilities.*`
but, when the Go CLI runs them as subprocesses with a different
working directory, `openant-core/` was not on `sys.path`. Both files
now prepend the openant-core root to `sys.path` before the
`utilities` import.
- **Anthropic SDK auth-error test broken by SDK update.**
`tests/test_silent_401.py` constructed `AuthenticationError("...")`
with a positional message; the current SDK requires
`AuthenticationError(message=, response=, body=)`. The test now
builds a mock `httpx.Response` and uses the keyword form, and
temporarily restores the real `anthropic` module so the real
exception class is used.
- **`run_utf8` explicit-encoding test crashed on Windows.**
`test_run_utf8_does_not_override_explicit_encoding` used
`print('café')` from a `-c` snippet, which itself fails to encode
on a cp1252 console before `run_utf8` even runs. The test now writes
raw `latin-1` bytes via `sys.stdout.buffer.write(...)` so the
encoding-override path is the thing under test on every platform.
- **`withTempHome` test helper didn't work on Windows.** Both copies
(`apps/openant-cli/cmd/mode_test.go` and
`apps/openant-cli/internal/config/scan_meta_test.go`) only set
`HOME`, but `os.UserHomeDir()` on Windows reads `USERPROFILE`. The
helpers now branch on `runtime.GOOS` and set the correct env var.

### Added

- **CI now lints for missing imports and undefined names.** A
`ruff check .` step runs in the `python-tests` job before `pytest`,
with `select = ["F821", "F811"]` (undefined name, redefined unused
name). Both rules are zero-false-positive runtime-bug catchers, so
contributors get fast static feedback on the kind of mistake Python
won't surface until the affected code path executes. Scoped narrowly
on purpose — widening to additional pyflakes rules can come later.
- **CI now runs Go unit tests on every platform.** A new
`go test ./... -v` step runs in the `go-tests` job before the build,
on Ubuntu, macOS, and Windows. Catches regressions like the venv
path bug above before the binary is built. The Python step also
switched from a hand-curated test list to `pytest tests/`, picking
up ten previously-CI-invisible test files (UTF-8 file I/O, Windows
path handling, dedup, cwe-tagging, evidence-tier, and others).

## [2026-05-07] — Incremental scans + scan pipeline rewire

### Changed
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ openant set-api-key <your-key>

**The key must have access to the Claude Opus 4.6 model.** Get a key at [console.anthropic.com](https://console.anthropic.com/settings/keys).

### Python runtime

OpenAnt's parsing, enhancement, analysis, and reporting code is Python 3.11+. The Go CLI picks an interpreter in this order:

1. `OPENANT_PYTHON` env var (set this to pin a specific interpreter — e.g. `OPENANT_PYTHON=python3.11`).
2. Managed venv at `~/.openant/venv/` (auto-created on first use). The CLI uses `bin/python` on Linux/macOS and `Scripts\python.exe` on Windows.
3. `python3` / `python` on `PATH`.

If none yield Python 3.11+, the command exits with an error pointing at [python.org](https://www.python.org/downloads/). To rebuild a stale managed venv (e.g. after upgrading Python), delete `~/.openant/venv/` and rerun any `openant` command.

## Data directories

OpenAnt creates two directories:
Expand Down
12 changes: 8 additions & 4 deletions apps/openant-cli/cmd/mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -123,13 +124,16 @@ func TestSelectModeNoFlagsNoBaselineGoesFull(t *testing.T) {
}
}

// Reuse helper from scan_meta_test.go's withTempHome. The cmd package
// can't import _test.go files from another package, so we redeclare a
// minimal copy here.
// Helper to set up a temporary home directory for tests.
// On Unix: sets HOME. On Windows: sets USERPROFILE.
func withTempHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", dir)
} else {
t.Setenv("HOME", dir)
}
return dir
}

Expand Down
12 changes: 9 additions & 3 deletions apps/openant-cli/internal/config/scan_meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)

// withTempHome points HOME at a temp dir for the duration of the test so
// ProjectDir / ScanRunDir resolve under there. Restores HOME on cleanup.
// withTempHome points the home directory at a temp dir for the duration of the test so
// ProjectDir / ScanRunDir resolve under there. Restores on cleanup.
// On Unix: sets HOME. On Windows: sets USERPROFILE.
func withTempHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", dir)
} else {
t.Setenv("HOME", dir)
}
return dir
}

Expand Down
14 changes: 9 additions & 5 deletions apps/openant-cli/internal/python/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
Expand Down Expand Up @@ -40,7 +41,11 @@ func venvDir() string {

// venvPython returns the path to the Python binary inside the managed venv.
func venvPython() string {
return filepath.Join(venvDir(), "bin", "python")
base := venvDir()
if runtime.GOOS == "windows" {
return filepath.Join(base, "Scripts", "python.exe")
}
return filepath.Join(base, "bin", "python")
}

// DetectRuntime finds a suitable Python 3.11+ installation.
Expand All @@ -51,10 +56,9 @@ func venvPython() string {
// 2. Managed venv at ~/.openant/venv/ (if it exists and is valid)
// 3. python3 / python on PATH
//
// Note: the managed-venv path (strategy 2) uses "bin/python" which is correct
// on Linux/macOS. On Windows the venv layout uses "Scripts\python.exe"; users
// on Windows who rely on the managed venv should set OPENANT_PYTHON explicitly
// to point at the desired interpreter.
// The managed-venv path (strategy 2) automatically detects the correct Python
// binary location based on the OS: "bin/python" on Unix-like systems, or
// "Scripts/python.exe" on Windows.
func DetectRuntime() (*RuntimeInfo, error) {
// Strategy 0: honour explicit override via OPENANT_PYTHON env var.
// If the override is set but unusable, warn and fall through rather than
Expand Down
47 changes: 47 additions & 0 deletions apps/openant-cli/internal/python/runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package python

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestVenvPython_Windows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("test only runs on Windows")
}

vp := venvPython()
expected := filepath.Join(os.Getenv("USERPROFILE"), ".openant", "venv", "Scripts", "python.exe")
if vp != expected {
t.Errorf("venvPython() = %q, want %q", vp, expected)
}

if !filepath.IsAbs(vp) {
t.Errorf("venvPython() should return absolute path, got %q", vp)
}
}

func TestVenvPython_Unix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test only runs on Unix-like systems")
}

vp := venvPython()
if !filepath.IsAbs(vp) {
t.Errorf("venvPython() should return absolute path, got %q", vp)
}

if !strings.HasSuffix(vp, filepath.Join("bin", "python")) {
t.Errorf("venvPython() on Unix should end with bin/python, got %q", vp)
}
}

func TestVenvDir_ReturnsAbsolutePath(t *testing.T) {
vd := venvDir()
if !filepath.IsAbs(vd) {
t.Errorf("venvDir() should return absolute path, got %q", vd)
}
}
27 changes: 15 additions & 12 deletions libs/openant-core/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from anthropic import Anthropic
from dotenv import load_dotenv
from utilities.file_io import open_utf8, read_json, write_json

# Load environment variables
load_dotenv()
Expand Down Expand Up @@ -208,7 +209,8 @@ def gather_context_sources(repo_path: Path) -> dict[str, str]:
filepath = repo_path / filename
if filepath.exists():
try:
content = filepath.read_text(errors="ignore")
with open_utf8(filepath, errors="ignore") as _f:
content = _f.read()
# Limit size to avoid token overflow
if len(content) > 10000:
content = content[:10000] + "\n\n[... truncated ...]"
Expand Down Expand Up @@ -289,7 +291,8 @@ def detect_entry_points(repo_path: Path) -> str:
continue

try:
content = py_file.read_text(errors="ignore")
with open_utf8(py_file, errors="ignore") as _f:
content = _f.read()
rel_path = py_file.relative_to(repo_path)

for category, patterns in ENTRY_POINT_PATTERNS.items():
Expand All @@ -308,7 +311,8 @@ def detect_entry_points(repo_path: Path) -> str:
continue

try:
content = js_file.read_text(errors="ignore")
with open_utf8(js_file, errors="ignore") as _f:
content = _f.read()
rel_path = js_file.relative_to(repo_path)

if re.search(r"express\(\)|require\(['\"]express['\"]\)", content):
Expand Down Expand Up @@ -340,15 +344,17 @@ def check_manual_override(repo_path: Path) -> ApplicationContext | None:
continue

try:
content = filepath.read_text()

if filename.endswith('.json'):
# Direct JSON format
data = json.loads(content)
data = read_json(filepath)
data['source'] = 'manual'
return ApplicationContext(**data)

elif filename.endswith('.md'):
# .md files need raw text so regex can extract the embedded JSON block.
with open_utf8(filepath) as _f:
content = _f.read()

if filename.endswith('.md'):
# Markdown format - check for JSON code block
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
if json_match:
Expand Down Expand Up @@ -545,8 +551,7 @@ def save_context(context: ApplicationContext, output_path: Path) -> None:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)

with open(output_path, 'w') as f:
json.dump(asdict(context), f, indent=2)
write_json(output_path, asdict(context))

print(f"Context saved to {output_path}", file=sys.stderr)

Expand All @@ -560,9 +565,7 @@ def load_context(input_path: Path) -> ApplicationContext:
Returns:
ApplicationContext loaded from file.
"""
with open(input_path) as f:
data = json.load(f)

data = read_json(input_path)
# Mark as manual to skip validation (already validated when saved)
original_source = data.get('source', 'llm')
data['source'] = 'manual' # Temporarily bypass validation
Expand Down
Loading
Loading