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
18 changes: 18 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Release Notes

## v0.9.5 — Cross-platform audit Pass-1 (Windows) & C# support

### Fixed

- **`purlin:audit` now runs on Windows.** The audit toolchain shells out to `scripts/audit/static_checks.py`, which was unusable on Windows for three independent reasons, each fixed here (issue #3):
- **`import fcntl` crashed the module on load.** `fcntl` is POSIX-only, so *every* subcommand (`--read-cache`, `--load-criteria`, `--check-proof-file`, Pass 1, `--write-cache`, `--prune-cache`) died with `ModuleNotFoundError` before doing any work. `fcntl` is now imported under `try/except ImportError` behind a `_HAS_FCNTL` flag, and the audit-cache lock goes through `_lock_exclusive`/`_unlock` helpers that use `fcntl.flock` on POSIX and `msvcrt.locking` on Windows — preserving the exclusive read→merge→write serialization that keeps concurrent subagent writers from clobbering each other (`static_checks` RULE-25/RULE-29).
- **Text I/O assumed UTF-8.** ~16 `open()` calls omitted `encoding=`, so on a Windows cp1252/ASCII locale they raised `UnicodeDecodeError` reading the tool's own UTF-8 files (e.g. `references/audit_criteria.md`). Every text-mode `open()` now specifies `encoding='utf-8'` (`static_checks` RULE-30).
- **`--load-criteria` printed non-ASCII to a non-UTF-8 console.** Even after the read fix, the criteria text (which contains `✓`/`⚠` glyphs) raised `UnicodeEncodeError` on an ASCII stdout. `static_checks.py` now reconfigures stdout/stderr to UTF-8 at startup (`static_checks` RULE-30).

### Added

- **Deterministic Pass-1 coverage for C#/.NET tests.** Pass 1 previously recognized only pytest/Jest/Shell markers, so xUnit/NUnit/MSTest tests carrying `[Trait("PurlinProof", "feature:PROOF-N:RULE-N:tier")]` produced zero proofs and the structural checker silently no-op'd on `.cs` files. The new `check_csharp` analyzer parses those trait markers, locates each `[Fact]`/`[Theory]` method body via a C#-aware brace/string scanner, and runs assert-true / no-assertion detection — recognizing xUnit `Assert.*`, NUnit `Assert.That`, MSTest `Assert.*`, and FluentAssertions `.Should()` as assertions (`static_checks` RULE-31). The dispatch was extracted into `analyze_test_file()` and now routes `.cs`. `references/supported_frameworks.md` documents the new coverage.
- **Portable interpreter guidance in `purlin:audit`.** `skills/audit/SKILL.md` documents a `python3 → python → py -3` fallback for invoking `static_checks.py`, since stock Windows does not put `python3` on PATH (`skill_audit` RULE-15).

### Testing

- New proofs: `static_checks` PROOF-44/45 (AST structural guards — no unconditional `fcntl` import; `encoding='utf-8'` on every text-mode `open()`), PROOF-46–49 (C# assert-true / no-assertion / multi-framework assertion recognition / `.cs` dispatch), PROOF-50 (Windows lock path driven through an injected fake `msvcrt`, `@integration`), PROOF-51 (full CLI pipeline via real subprocess, `@e2e`), PROOF-52 (the cp1252 failure reproduced by running `--load-criteria` under `PYTHONUTF8=0`/`LC_ALL=C`, `@e2e`); `skill_audit` PROOF-15. Windows behavior is verified by simulation (fake `msvcrt`) plus a faithful ASCII-locale subprocess repro — not yet a Windows CI runner. Independent audit of the new proofs: 7/7 behavioral proofs STRONG (100% integrity), 0 WEAK/HOLLOW. `static_checks` VERIFIED at 29/29, `skill_audit` at 15/15.

## v0.9.4 — Plugin-bundled MCP server & e2e proof quality

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.4
0.9.5
112 changes: 112 additions & 0 deletions dev/test_e2e_audit_cache_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,3 +1692,115 @@ def _write_receipt(name, vhash):
f"CLI summary table should show {name} as {exp_status}\n"
f"CLI output:\n{cli_output}"
)


# ---------------------------------------------------------------------------
# Cross-platform portability (issue #3): Windows lock path + UTF-8 stdio
# ---------------------------------------------------------------------------

_STATIC_CHECKS_PY = os.path.join(
os.path.dirname(__file__), '..', 'scripts', 'audit', 'static_checks.py'
)
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))


def _audit_entry(assessment, feature, proof_id, rule_id):
return {
"assessment": assessment,
"criterion": "matches rule intent",
"why": "test exercises the rule correctly",
"fix": "none",
"feature": feature,
"proof_id": proof_id,
"rule_id": rule_id,
"priority": "LOW",
"cached_at": "2026-04-01T00:00:00+00:00",
}


class _FakeMsvcrt:
"""Stand-in for the Windows msvcrt module — records locking() calls."""
LK_LOCK = 1
LK_UNLCK = 0

def __init__(self):
self.calls = []

def locking(self, fd, mode, nbytes):
self.calls.append(mode)
return None


class TestCrossPlatformCachePipeline:
"""RULE-29/30: the audit-cache pipeline works on Windows (no fcntl, ASCII locale)."""

@pytest.mark.proof("static_checks", "PROOF-50", "RULE-29", tier="integration")
def test_windows_lock_path_via_fake_msvcrt(self):
"""With fcntl unavailable and msvcrt faked, write_audit_cache round-trips
and drives the msvcrt LK_LOCK / LK_UNLCK lock path."""
fake = _FakeMsvcrt()
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.object(static_checks, '_HAS_FCNTL', False), \
mock.patch.dict(sys.modules, {'msvcrt': fake}):
write_audit_cache(tmpdir, {
"h1": _audit_entry("STRONG", "feat_a", "PROOF-1", "RULE-1"),
"h2": _audit_entry("WEAK", "feat_a", "PROOF-2", "RULE-2"),
})
after = read_audit_cache(tmpdir)
assert len(after) == 2, f"entries lost on Windows lock path: {list(after)}"
assert "h1" in after and "h2" in after
assert fake.calls == [fake.LK_LOCK, fake.LK_UNLCK], (
f"expected LK_LOCK then LK_UNLCK via msvcrt, got {fake.calls}"
)

@pytest.mark.proof("static_checks", "PROOF-51", "RULE-29", tier="e2e")
def test_cli_pipeline_subprocess(self):
"""Drive the real CLI as a subprocess: write-cache (stdin) -> read-cache ->
Pass-1 on a hollow .cs fixture; observe JSON output and the cache on disk."""
with tempfile.TemporaryDirectory() as tmpdir:
entries = {"h1": _audit_entry("STRONG", "feat", "PROOF-1", "RULE-1")}
w = subprocess.run(
[sys.executable, _STATIC_CHECKS_PY, '--write-cache', '--project-root', tmpdir],
input=json.dumps(entries), capture_output=True, text=True)
assert w.returncode == 0, w.stderr
assert json.loads(w.stdout)['status'] == 'merged'

cache_path = os.path.join(tmpdir, '.purlin', 'cache', 'audit_cache.json')
assert os.path.isfile(cache_path), "audit_cache.json was not written"

r = subprocess.run(
[sys.executable, _STATIC_CHECKS_PY, '--read-cache', '--project-root', tmpdir],
capture_output=True, text=True)
assert r.returncode == 0, r.stderr
assert 'h1' in json.loads(r.stdout)

cs = os.path.join(tmpdir, 'Tests.cs')
with open(cs, 'w', encoding='utf-8') as f:
f.write('public class T {\n'
' [Fact]\n'
' [Trait("PurlinProof", "feat:PROOF-1:RULE-1:unit")]\n'
' public void X() { Assert.True(true); }\n}\n')
p = subprocess.run([sys.executable, _STATIC_CHECKS_PY, cs, 'feat'],
capture_output=True, text=True)
assert p.returncode == 0, p.stderr
proofs = json.loads(p.stdout)['proofs']
assert any(pr.get('check') == 'assert_true' for pr in proofs), proofs

@pytest.mark.proof("static_checks", "PROOF-52", "RULE-30", tier="e2e")
def test_load_criteria_under_ascii_locale(self):
"""Reproduce the Windows cp1252 default: run --load-criteria under an ASCII
locale over the tool's own non-ASCII audit_criteria.md. Must not raise
UnicodeDecodeError (read) or UnicodeEncodeError (stdout)."""
env = dict(os.environ)
env['PYTHONUTF8'] = '0'
env['LC_ALL'] = 'C'
env['LANG'] = 'C'
with tempfile.TemporaryDirectory() as tmpdir:
r = subprocess.run(
[sys.executable, _STATIC_CHECKS_PY, '--load-criteria', '--project-root', tmpdir],
cwd=_REPO_ROOT, env=env, capture_output=True, text=True)
assert r.returncode == 0, (
f"--load-criteria crashed under ASCII locale:\n{r.stderr}")
# Criteria carry non-ASCII glyphs; their presence proves UTF-8 stdout.
assert any(ord(ch) > 127 for ch in r.stdout), \
"criteria output had no non-ASCII character — stdout not UTF-8?"
9 changes: 9 additions & 0 deletions dev/test_skill_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ def test_load_criteria_assembles_builtin_plus_additional_and_pass1_still_works(s
finally:
shutil.rmtree(tmp_dir)

@pytest.mark.proof("skill_audit", "PROOF-15", "RULE-15")
def test_documents_portable_interpreter_fallback(self):
"""SKILL.md documents python -> py -3 fallbacks for python3 (Windows PATH)."""
content = _read('audit')
assert 'py -3' in content, \
"audit SKILL.md must document the 'py -3' interpreter fallback"
assert '`python`' in content or 'then `python`' in content or 'python`,' in content, \
"audit SKILL.md must document the 'python' interpreter fallback"


# ── skill_build ───────────────────────────────────────────────────────

Expand Down
176 changes: 164 additions & 12 deletions dev/test_static_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
proof-file structural checks (proof_id_collision, proof_rule_orphan).
"""

import ast
import json
import os
import subprocess
Expand All @@ -18,6 +19,8 @@
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts', 'audit'))
import static_checks
from static_checks import (
analyze_test_file,
check_csharp,
check_proof_file,
check_python,
check_shell,
Expand Down Expand Up @@ -1102,28 +1105,27 @@ def _make_entry(self, assessment, feature, proof_id, rule_id):

@pytest.mark.proof("static_checks", "PROOF-40", "RULE-25")
def test_lock_file_created_alongside_cache(self):
"""write_audit_cache creates audit_cache.json.lock adjacent to the cache file."""
import fcntl
import unittest.mock as mock
"""write_audit_cache acquires the exclusive lock on audit_cache.json.lock.

Patches the platform-neutral _lock_exclusive helper (not fcntl directly)
so the test runs identically on Windows and POSIX.
"""
with tempfile.TemporaryDirectory() as tmpdir:
lock_path = os.path.join(tmpdir, '.purlin', 'cache', 'audit_cache.json.lock')
lock_seen = []
real_lock = static_checks._lock_exclusive

original_flock = fcntl.flock
def spy(lock_file):
lock_seen.append(os.path.exists(lock_path))
return real_lock(lock_file)

def patched_flock(fd, op):
if op == fcntl.LOCK_EX:
lock_seen.append(os.path.exists(lock_path))
return original_flock(fd, op)

with mock.patch('fcntl.flock', side_effect=patched_flock):
with mock.patch.object(static_checks, '_lock_exclusive', side_effect=spy):
write_audit_cache(tmpdir, {
"hash1": self._make_entry("STRONG", "feat_a", "PROOF-1", "RULE-1"),
})

assert lock_seen, "flock(LOCK_EX) was never called"
assert lock_seen[0], "lock file did not exist when flock was called"
assert lock_seen, "_lock_exclusive was never called"
assert lock_seen[0], "lock file did not exist when the lock was acquired"

@pytest.mark.proof("static_checks", "PROOF-40", "RULE-25")
def test_concurrent_writes_preserve_all_entries(self):
Expand Down Expand Up @@ -1315,3 +1317,153 @@ def test_check_js_tokenizer_handles_braces_and_apostrophes(self):
assert proofs["PROOF-1"]["status"] == "pass", proofs["PROOF-1"]
assert proofs["PROOF-1"].get("check") != "no_assertions"
assert proofs["PROOF-2"]["status"] == "pass"


class TestCrossPlatformPortability:
"""RULE-29/30: static_checks.py imports and runs on Windows as well as POSIX."""

def _source(self):
with open(STATIC_CHECKS_PY, encoding='utf-8') as f:
return f.read()

@pytest.mark.proof("static_checks", "PROOF-44", "RULE-29")
def test_no_unconditional_fcntl_import(self):
"""fcntl is imported under try/except ImportError, never unconditionally,
and _HAS_FCNTL is assigned in both branches."""
tree = ast.parse(self._source())

# No top-level `import fcntl` in the module body.
for node in tree.body:
if isinstance(node, ast.Import):
assert 'fcntl' not in [a.name for a in node.names], \
"fcntl is imported unconditionally at module level"

fcntl_in_try = False
flag_in_try = False
flag_in_except = False
for node in tree.body:
if not isinstance(node, ast.Try):
continue
for stmt in node.body:
if isinstance(stmt, ast.Import) and any(a.name == 'fcntl' for a in stmt.names):
fcntl_in_try = True
if isinstance(stmt, ast.Assign) and any(
getattr(t, 'id', None) == '_HAS_FCNTL' for t in stmt.targets):
flag_in_try = True
# The except must catch ImportError and set the flag.
catches_import_error = any(
h.type is not None and isinstance(h.type, ast.Name) and h.type.id == 'ImportError'
for h in node.handlers)
for h in node.handlers:
if any(isinstance(s, ast.Assign) and any(
getattr(t, 'id', None) == '_HAS_FCNTL' for t in s.targets) for s in h.body):
flag_in_except = catches_import_error

assert fcntl_in_try, "fcntl is not imported inside a try block"
assert flag_in_try and flag_in_except, \
"_HAS_FCNTL must be set in both the try and except ImportError branches"
assert hasattr(static_checks, '_HAS_FCNTL'), "module exposes no _HAS_FCNTL flag"

@pytest.mark.proof("static_checks", "PROOF-45", "RULE-30")
def test_all_text_open_calls_specify_utf8(self):
"""Every text-mode open() in static_checks.py passes encoding='utf-8'."""
tree = ast.parse(self._source())
offenders = []
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'open'):
continue
# Resolve the mode (2nd positional arg or mode= kwarg).
mode = None
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
mode = node.args[1].value
for kw in node.keywords:
if kw.arg == 'mode' and isinstance(kw.value, ast.Constant):
mode = kw.value.value
if isinstance(mode, str) and 'b' in mode:
continue # binary mode takes no encoding
enc = None
for kw in node.keywords:
if kw.arg == 'encoding' and isinstance(kw.value, ast.Constant):
enc = kw.value.value
if enc != 'utf-8':
offenders.append(getattr(node, 'lineno', '?'))
assert not offenders, \
f"text-mode open() without encoding='utf-8' at lines: {offenders}"


class TestCheckCsharp:
"""RULE-31: deterministic Pass-1 checks for C#/.NET (xUnit/NUnit/MSTest) tests."""

def _cs(self, body, proof_id="PROOF-1", rule_id="RULE-1"):
return _write_tmp(f'''
using Xunit;
using FluentAssertions;
namespace Demo {{
public class Tests {{
[Fact]
[Trait("PurlinProof", "csfeat:{proof_id}:{rule_id}:unit")]
public void TheTest() {{ {body} }}
}}
}}
''', suffix='.cs')

@pytest.mark.proof("static_checks", "PROOF-46", "RULE-31")
def test_detects_assert_true(self):
path = self._cs("Assert.True(true);")
try:
results = check_csharp(path, "csfeat")
assert len(results) == 1
assert results[0]['status'] == 'fail'
assert results[0]['check'] == 'assert_true'
finally:
os.unlink(path)

@pytest.mark.proof("static_checks", "PROOF-47", "RULE-31")
def test_detects_no_assertions(self):
path = self._cs("var x = Compute(); var y = x + 1;")
try:
results = check_csharp(path, "csfeat")
assert len(results) == 1
assert results[0]['status'] == 'fail'
assert results[0]['check'] == 'no_assertions'
finally:
os.unlink(path)

@pytest.mark.proof("static_checks", "PROOF-48", "RULE-31")
def test_recognizes_all_assertion_frameworks(self):
"""xUnit Assert.Equal, NUnit Assert.That, MSTest Assert.IsTrue, and
FluentAssertions .Should() each count as a real assertion (status=pass)."""
cases = {
"xunit": "Assert.Equal(3, 1 + 2);",
"nunit": "Assert.That(2 + 2, Is.EqualTo(4));",
"mstest": "Assert.IsTrue(1 < 2);",
"fluent": 'var s = "hi { nested }"; s.Should().Contain("hi");',
}
for name, body in cases.items():
path = self._cs(body)
try:
results = check_csharp(path, "csfeat")
assert len(results) == 1, f"{name}: expected 1 proof"
assert results[0]['status'] == 'pass', \
f"{name}: assertion not recognized — {results[0]}"
finally:
os.unlink(path)

@pytest.mark.proof("static_checks", "PROOF-49", "RULE-31")
def test_dispatch_routes_cs_to_check_csharp(self):
"""analyze_test_file routes a .cs file to check_csharp (not the empty fallback)."""
path = self._cs("Assert.True(true);", proof_id="PROOF-7", rule_id="RULE-9")
try:
results = analyze_test_file(path, "csfeat")
assert results, ".cs file produced no proofs — dispatch fell through to []"
assert results[0]['proof_id'] == 'PROOF-7'
assert results[0]['check'] == 'assert_true'
# A genuinely unknown extension still yields the empty fallback.
other = _write_tmp("nothing here", suffix='.txt')
try:
assert analyze_test_file(other, "csfeat") == []
finally:
os.unlink(other)
finally:
os.unlink(path)
Loading