From 7846663d6c59d37188da8c30130256e4a613a776 Mon Sep 17 00:00:00 2001 From: rlabarca Date: Wed, 24 Jun 2026 15:55:27 -0400 Subject: [PATCH 1/5] spec(static_checks): cross-platform Pass-1 + C# support (RULE-29/30/31) Reword RULE-25 to drop the fcntl pin (POSIX flock / Windows msvcrt). Add RULE-29 (portable import + lock), RULE-30 (utf-8 file I/O), RULE-31 (C# .cs Pass-1 analyzer) with proofs PROOF-44..52. Add skill_audit RULE-15 + PROOF-15 (python3->python->py -3 interpreter probe). Note C# deterministic Pass-1 coverage in supported_frameworks.md. Addresses #3. Co-Authored-By: Claude Opus 4.8 (1M context) --- references/supported_frameworks.md | 2 ++ specs/audit/static_checks.md | 14 +++++++++++++- specs/skills/skill_audit.md | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/references/supported_frameworks.md b/references/supported_frameworks.md index 3781b910..30e68452 100644 --- a/references/supported_frameworks.md +++ b/references/supported_frameworks.md @@ -26,6 +26,8 @@ Shipped plugins that `purlin:init` does not yet auto-detect or scaffold — wire |-----------|-------------|-----------|------------|-----------|---------------|------| | **xUnit** | xunit (.NET) | C#, F#, VB.NET | `scripts/proof/xunit_purlin.cs` | `*.csproj` or `*.sln` present | `[Trait("PurlinProof", "feature:PROOF-1:RULE-1:unit")]` test trait | `specs/proof/proof_plugins_xunit.md` | +> **C# deterministic Pass-1 coverage:** the audit Pass-1 static checker (`scripts/audit/static_checks.py`) parses `.cs` test files directly — it reads the `[Trait("PurlinProof", ...)]` markers above, associates each with its `[Fact]`/`[Theory]` method body, and runs assert-true / no-assertion detection (recognizing xUnit `Assert.*`, NUnit `Assert.That`, MSTest `Assert.*`, and FluentAssertions `.Should()`). This is independent of the runtime proof plugin, which still records pass/fail during the actual test run. + > **Vitest version support:** the Vitest reporter (`vitest_purlin.ts`) collects proofs in the `onFinished(files)` hook, whose shape is stable across Vitest 2.x → 4.x (tested on 2.x and 3.x). Earlier `onTaskUpdate`-based collection broke silently on Vitest 2+ and is no longer used. Note that `jest_purlin.js` is **not** a drop-in for Vitest — Vitest does not call Jest's `onTestResult`/`onRunComplete` hooks, so Vitest projects use `vitest_purlin.ts`. ## End-to-end (browser) proofs diff --git a/specs/audit/static_checks.md b/specs/audit/static_checks.md index f4b84b39..2c1829e3 100644 --- a/specs/audit/static_checks.md +++ b/specs/audit/static_checks.md @@ -27,10 +27,13 @@ - RULE-22: prune_audit_cache removes all cache entries whose hash key is not in the provided live_keys set, preserving entries whose key IS in live_keys with all fields intact - RULE-23: prune_audit_cache with an empty live_keys set on a non-empty cache produces an empty cache (full sweep), and with all keys live produces an identical cache (no false pruning) - RULE-24: write_audit_cache merges new entries into the existing cache on disk — entries from prior writes for different features are preserved, not overwritten. Entries for the same (feature, proof_id) are deduplicated by keeping the latest cached_at -- RULE-25: write_audit_cache protects the entire read→merge→write cycle with an exclusive file lock (`fcntl.flock` on `audit_cache.json.lock`) so that concurrent subagent writers serialize correctly and no entries are lost +- RULE-25: write_audit_cache protects the entire read→merge→write cycle with an exclusive OS file lock on `audit_cache.json.lock` (`fcntl.flock` on POSIX, `msvcrt.locking` on Windows) so that concurrent subagent writers serialize correctly and no entries are lost - RULE-26: `--write-cache` CLI flag reads a JSON dict of cache entries from stdin and merges them into the existing audit cache via write_audit_cache, printing a JSON status response with `status: "merged"` and entry count - RULE-27: check_js detects tautological assertions (`expect(true).toBe(true)`) and JS/TS test bodies with no `expect()` calls, returning the same JSON shape (proof_id, rule_id, test_name, status, reason) as check_python - RULE-28: check_js parses JS/TS test files with a brace-balancing tokenizer that (a) matches test titles containing apostrophes regardless of quote style, and (b) captures full test bodies containing nested braces — options objects, destructured parameters, type assertions — without truncating at the first inner `}` +- RULE-29: static_checks.py imports and runs on Windows as well as POSIX — no Unix-only module is imported unconditionally at module load. `fcntl` is imported under `try/except ImportError` and a module-level flag (`_HAS_FCNTL`) selects between `fcntl.flock` and an `msvcrt.locking` fallback, so write_audit_cache acquires and releases its exclusive lock through that flag and the Windows lock path is exercisable without `fcntl` +- RULE-30: Every text-mode `open()` in static_checks.py specifies `encoding='utf-8'`, so the tool's own UTF-8 specs, proofs, criteria, config, and cache files parse and write identically regardless of the OS locale codec (e.g. cp1252 on Windows) +- RULE-31: For `.cs` test files, Pass 1 parses `[Trait("PurlinProof", "feature:PROOF-N:RULE-N:tier")]` traits, associates each with its `[Fact]`/`[Theory]` method body, and applies assert-true and no-assertion detection — returning the same JSON shape (proof_id, rule_id, test_name, status, reason) as check_python/check_js. xUnit `Assert.*`, NUnit `Assert.That`, MSTest `Assert.*`, and FluentAssertions `.Should()` all count as assertions ## Proof @@ -75,3 +78,12 @@ - PROOF-41 (RULE-26): Call `--write-cache` via CLI with JSON on stdin; verify entries are merged and status response is correct. Seed cache first, then call `--write-cache` with entries for a different feature; verify both old and new entries survive - PROOF-42 (RULE-27): e2e: Run the real static_checks.py CLI on a `.ts` file containing a `[proof:...]` test with `expect(true).toBe(true)`; verify status=fail check=assert_true. Run on one whose body has no `expect()`; verify status=fail check=no_assertions. Run on a clean test; verify status=pass @e2e - PROOF-43 (RULE-28): e2e: Run the real static_checks.py CLI on the issue #2 repro — `it("execSync options trigger early-truncation [proof:demo:PROOF-1:RULE-1]", () => { execSync("ls", { cwd: ".", encoding: "utf8" }); expect(out).toMatch(/./); })` and `it("cd's into a sibling [proof:demo:PROOF-2:RULE-2]", () => { expect(1).toBe(1); })`; verify BOTH PROOF-1 and PROOF-2 appear in the output (apostrophe title matched) and PROOF-1 is NOT flagged no_assertions (options-object body fully captured, expect() seen) @e2e +- PROOF-44 (RULE-29): Parse static_checks.py with the `ast` module; verify there is no unconditional top-level `import fcntl` (the import sits inside a `try/except ImportError`) and that a module-level `_HAS_FCNTL` assignment exists in both the try and except branches +- PROOF-45 (RULE-30): Parse static_checks.py with the `ast` module; collect every call to `open(...)` opened in text mode (no `'b'` in the mode argument) and verify each passes an `encoding='utf-8'` keyword +- PROOF-46 (RULE-31): Run check_csharp on a `.cs` file whose `[Trait("PurlinProof", ...)]`-marked test body is `Assert.True(true);`; verify status=fail check=assert_true +- PROOF-47 (RULE-31): Run check_csharp on a `.cs` file whose marked test body contains no assertion call; verify status=fail check=no_assertions +- PROOF-48 (RULE-31): Run check_csharp on marked `.cs` tests whose bodies use xUnit `Assert.Equal`, NUnit `Assert.That`, MSTest `Assert.IsTrue`, and FluentAssertions `.Should()` respectively; verify each is status=pass (the assertion is recognized, not flagged no_assertions) +- PROOF-49 (RULE-31): Run the static_checks main dispatch (by file extension) on a `.cs` test file; verify it routes to check_csharp and returns a non-empty proofs array rather than the empty-`[]` fallback for unknown extensions +- PROOF-50 (RULE-29): With `fcntl` forced unavailable (`_HAS_FCNTL=False`) and a recording fake `msvcrt` injected into `sys.modules`, call write_audit_cache; verify the cache round-trips intact AND the fake `msvcrt.locking` was invoked with `LK_LOCK` then `LK_UNLCK` @integration +- PROOF-51 (RULE-29): e2e: Invoke the real static_checks.py CLI as a subprocess on the host (`--write-cache` via stdin, then `--read-cache`, then Pass-1 on a hollow `.cs` fixture); verify valid JSON is emitted on stdout at each step and `.purlin/cache/audit_cache.json` is written to disk @e2e +- PROOF-52 (RULE-30): e2e: Invoke the real static_checks.py CLI as a subprocess with `env PYTHONUTF8=0` and `LC_ALL=C` (simulating the Windows cp1252 default), pointing it at a UTF-8 file containing a non-ASCII byte; verify it completes without `UnicodeDecodeError` and emits valid JSON @e2e diff --git a/specs/skills/skill_audit.md b/specs/skills/skill_audit.md index d4660d77..dff875fd 100644 --- a/specs/skills/skill_audit.md +++ b/specs/skills/skill_audit.md @@ -20,6 +20,7 @@ - RULE-12: Two-pass flow works end-to-end: static_checks catches HOLLOW in Pass 1, only surviving proofs go to external LLM in Pass 2 - RULE-13: Custom audit LLM command in config responds to ping, config stores audit_llm and audit_llm_name, and the two-pass audit completes - RULE-14: Criteria are loaded via the single `load_criteria()` function (`--load-criteria` CLI); built-in criteria always apply, additional team criteria are appended — never replaced +- RULE-15: The skill documents a portable Python interpreter for invoking static_checks.py — falling back from `python3` to `python` to `py -3` — rather than assuming `python3` is always on PATH (it is not on stock Windows) ## Proof @@ -37,3 +38,4 @@ - PROOF-12 (RULE-12): e2e: Mixed-quality test file; static_checks catches assert True; valid test goes to external LLM @e2e - PROOF-13 (RULE-13): e2e: Write config with fake LLM command; verify ping, config fields, and two-pass audit @e2e - PROOF-14 (RULE-14): e2e: Create fake git repo with additional criteria; configure project; verify load_criteria returns built-in + additional with separator; verify Pass 1 still catches assert True; verify additional criteria reach fake LLM prompt @e2e +- PROOF-15 (RULE-15): Grep `skills/audit/SKILL.md` for the interpreter-fallback guidance; verify it documents `python` and `py -3` as fallbacks for `python3` From 2259011e512dffa219356892dc1187651a58b6ba Mon Sep 17 00:00:00 2001 From: rlabarca Date: Wed, 24 Jun 2026 16:15:58 -0400 Subject: [PATCH 2/5] fix(static_checks): cross-platform audit Pass-1 + C# support (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make scripts/audit/static_checks.py run on Windows, where purlin:audit was completely broken: - RULE-29: import fcntl under try/except ImportError with a _HAS_FCNTL flag; add _lock_exclusive/_unlock that use fcntl.flock on POSIX and msvcrt.locking on Windows (retry loop matches flock's blocking semantics). write_audit_cache locks through the helpers. - RULE-30: add encoding='utf-8' to every text-mode open(), and reconfigure stdout/stderr to UTF-8 at startup so --load-criteria no longer dies with UnicodeDecodeError (reading audit_criteria.md) or UnicodeEncodeError (printing its ✓/⚠ glyphs) under a cp1252/ASCII locale. - RULE-31: add check_csharp — parses [Trait("PurlinProof", ...)] markers, finds each [Fact]/[Theory] body via a C#-aware brace scanner, and runs assert-true / no-assertion detection (xUnit/NUnit/MSTest Assert.*, FluentAssertions .Should()). Extract analyze_test_file() dispatch seam; route .cs to it. - skill_audit RULE-15: SKILL.md documents a python3 -> python -> py -3 fallback. Tests: PROOF-44/45 (AST guards), 46-49 (C#), 50 (Windows-sim msvcrt lock, @integration), 51 (POSIX subprocess pipeline, @e2e), 52 (cp1252 locale repro via --load-criteria, @e2e); skill_audit PROOF-15. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/test_e2e_audit_cache_pipeline.py | 112 +++++++ dev/test_skill_specs.py | 9 + dev/test_static_checks.py | 176 ++++++++++- scripts/audit/static_checks.py | 285 ++++++++++++++++-- skills/audit/SKILL.md | 2 + specs/audit/static_checks.proofs-e2e.json | 18 ++ .../static_checks.proofs-integration.json | 9 + specs/audit/static_checks.proofs-unit.json | 54 ++++ specs/skills/skill_audit.proofs-unit.json | 9 + 9 files changed, 633 insertions(+), 41 deletions(-) diff --git a/dev/test_e2e_audit_cache_pipeline.py b/dev/test_e2e_audit_cache_pipeline.py index a2a2bdad..3e3dae6f 100644 --- a/dev/test_e2e_audit_cache_pipeline.py +++ b/dev/test_e2e_audit_cache_pipeline.py @@ -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?" diff --git a/dev/test_skill_specs.py b/dev/test_skill_specs.py index 50cb5ad6..ccefd874 100644 --- a/dev/test_skill_specs.py +++ b/dev/test_skill_specs.py @@ -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 ─────────────────────────────────────────────────────── diff --git a/dev/test_static_checks.py b/dev/test_static_checks.py index 82374a55..5fe75629 100644 --- a/dev/test_static_checks.py +++ b/dev/test_static_checks.py @@ -6,6 +6,7 @@ proof-file structural checks (proof_id_collision, proof_rule_orphan). """ +import ast import json import os import subprocess @@ -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, @@ -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): @@ -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) diff --git a/scripts/audit/static_checks.py b/scripts/audit/static_checks.py index 15905771..e7713f45 100755 --- a/scripts/audit/static_checks.py +++ b/scripts/audit/static_checks.py @@ -13,13 +13,18 @@ import ast import datetime -import fcntl import hashlib import json import os import re import sys +try: + import fcntl # POSIX only — absent on Windows + _HAS_FCNTL = True +except ImportError: # Windows: fall back to msvcrt-based locking (imported lazily) + _HAS_FCNTL = False + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -255,7 +260,7 @@ def _check_mock_target_match(node, source, rule_desc): def check_python(filepath, feature_name, rule_descs=None): """Run all Python checks. Returns list of proof result dicts.""" rule_descs = rule_descs or {} - with open(filepath) as f: + with open(filepath, encoding='utf-8') as f: source = f.read() proofs = _get_python_proofs_and_functions(source, feature_name) results = [] @@ -299,7 +304,7 @@ def check_python(filepath, feature_name, rule_descs=None): def check_shell(filepath, feature_name): """Run shell test checks. Returns list of proof result dicts.""" - with open(filepath) as f: + with open(filepath, encoding='utf-8') as f: content = f.read() lines = content.splitlines() results = [] @@ -554,7 +559,7 @@ def _find_test_body(content, i): def check_js(filepath, feature_name): """Run JS/TS test checks. Returns list of proof result dicts.""" - with open(filepath) as f: + with open(filepath, encoding='utf-8') as f: content = f.read() results = [] call_re = re.compile(r'\b(?:it|test)\s*\(') @@ -615,6 +620,191 @@ def check_js(filepath, feature_name): }) return results + +# --------------------------------------------------------------------------- +# C# / .NET (xUnit / NUnit / MSTest) checks +# --------------------------------------------------------------------------- + +def _read_csharp_balanced(content, i, opener, closer): + """content[i] is `opener`. Return (inner_text, index_after_matching_close). + + Skips C# strings (regular, verbatim @"", interpolated $""), char literals, + and // and /* */ comments so their contents never affect bracket depth. + """ + n = len(content) + depth = 0 + j = i + start_inner = i + 1 + while j < n: + c = content[j] + if c == '/' and content[j + 1:j + 2] == '/': + nl = content.find('\n', j) + j = n if nl < 0 else nl + continue + if c == '/' and content[j + 1:j + 2] == '*': + e = content.find('*/', j + 2) + j = n if e < 0 else e + 2 + continue + if c == '@' and content[j + 1:j + 2] == '"': # verbatim string: "" escapes a quote + j += 2 + while j < n: + if content[j] == '"': + if content[j + 1:j + 2] == '"': + j += 2 + continue + j += 1 + break + j += 1 + continue + if c == '"': # regular or interpolated string ($ prefix already passed over) + j += 1 + while j < n: + if content[j] == '\\': + j += 2 + continue + if content[j] == '"': + j += 1 + break + j += 1 + continue + if c == "'": # char literal + j += 1 + while j < n: + if content[j] == '\\': + j += 2 + continue + if content[j] == "'": + j += 1 + break + j += 1 + continue + if c == opener: + depth += 1 + elif c == closer: + depth -= 1 + if depth == 0: + return content[start_inner:j], j + 1 + j += 1 + return content[start_inner:j], j # unterminated + + +def _find_csharp_body(content, i): + """From index i (just after a [Trait(...)] marker), locate the test method's + { body }. Skips trailing attributes ([Fact], [InlineData(...)]) and the + method signature/parameter list. Returns (body_text, index_after_body, + method_name), or (None, i, method_name) for expression-bodied or abstract + members with no block body. + """ + n = len(content) + method_name = None + while i < n: + c = content[i] + if c == '/' and content[i + 1:i + 2] == '/': + nl = content.find('\n', i) + i = n if nl < 0 else nl + continue + if c == '/' and content[i + 1:i + 2] == '*': + e = content.find('*/', i + 2) + i = n if e < 0 else e + 2 + continue + if c == '[': # another attribute or an array — skip balanced [...] + _, i = _read_csharp_balanced(content, i, '[', ']') + continue + if c == '(': # the identifier just before this paren is the method name + k = i - 1 + while k >= 0 and content[k].isspace(): + k -= 1 + end = k + 1 + while k >= 0 and (content[k].isalnum() or content[k] == '_'): + k -= 1 + method_name = content[k + 1:end] or method_name + _, i = _read_csharp_balanced(content, i, '(', ')') + continue + if content.startswith('=>', i): + return None, i, method_name # expression-bodied member — no block + if c == ';': + return None, i, method_name # no body + if c == '{': + body, after = _read_csharp_balanced(content, i, '{', '}') + return body, after, method_name + i += 1 + return None, i, method_name + + +def check_csharp(filepath, feature_name, rule_descs=None): + """Run C#/.NET (xUnit/NUnit/MSTest) test checks. Returns list of proof dicts. + + Parses `[Trait("PurlinProof", "feature:PROOF-N:RULE-N:tier")]` markers, finds + each marked test method's body, and applies assert-true / no-assertion + detection. Recognizes xUnit `Assert.*`, NUnit `Assert.That`, MSTest `Assert.*`, + and FluentAssertions `.Should()` as assertions. + """ + with open(filepath, encoding='utf-8') as f: + content = f.read() + results = [] + marker_re = re.compile( + r'\[\s*Trait\s*\(\s*"PurlinProof"\s*,\s*"' + + re.escape(feature_name) + + r':([^:"\]]+):([^:"\]]+):[^"]*"\s*\)\s*\]' + ) + for m in marker_re.finditer(content): + proof_id = m.group(1) + rule_id = m.group(2) + body, _after, method_name = _find_csharp_body(content, m.end()) + if body is None: + # No block body to inspect (expression-bodied or abstract) — skip. + continue + test_name = (method_name or proof_id)[:60] + + # assert_true: tautological assertions across the supported frameworks. + if (re.search(r'Assert\s*\.\s*(?:True|IsTrue)\s*\(\s*true\s*\)', body) + or re.search(r'Assert\s*\.\s*(?:Equal|AreEqual)\s*\(\s*true\s*,\s*true\s*\)', body)): + results.append({ + 'proof_id': proof_id, 'rule_id': rule_id, + 'test_name': test_name, 'status': 'fail', + 'check': 'assert_true', 'reason': 'Assert.True(true) is tautological', + 'literal': True, + }) + continue + + # no_assertions: no recognized assertion call in the body. + # xUnit/NUnit/MSTest `Assert.`, FluentAssertions `.Should(`, Moq `.Verify(`. + if (not re.search(r'\bAssert\s*\.', body) + and not re.search(r'\.\s*Should\s*\(', body) + and not re.search(r'\.\s*Verify\s*\(', body)): + results.append({ + 'proof_id': proof_id, 'rule_id': rule_id, + 'test_name': test_name, 'status': 'fail', + 'check': 'no_assertions', + 'reason': 'test method has no Assert./.Should()/.Verify() call', + }) + continue + + results.append({ + 'proof_id': proof_id, 'rule_id': rule_id, + 'test_name': test_name, 'status': 'pass', + 'reason': 'structural checks passed', + }) + return results + + +def analyze_test_file(test_file, feature_name, rule_descs=None): + """Dispatch a test file to the language checker matching its extension. + + Returns the list of proof result dicts, or [] for unsupported extensions. + """ + ext = os.path.splitext(test_file)[1].lower() + if ext == '.py': + return check_python(test_file, feature_name, rule_descs) + if ext == '.sh': + return check_shell(test_file, feature_name) + if ext in ('.js', '.ts', '.jsx', '.tsx'): + return check_js(test_file, feature_name) + if ext == '.cs': + return check_csharp(test_file, feature_name, rule_descs) + return [] + + # --------------------------------------------------------------------------- # Spec reading (for mock_target_match) # --------------------------------------------------------------------------- @@ -625,7 +815,7 @@ def _read_rule_descriptions(spec_path): """Read rule descriptions from a spec file.""" if not spec_path or not os.path.isfile(spec_path): return {} - with open(spec_path) as f: + with open(spec_path, encoding='utf-8') as f: content = f.read() return {m.group(1): m.group(2).strip() for m in _RULE_LINE_RE.finditer(content)} @@ -645,7 +835,7 @@ def _read_proof_descriptions(spec_path): """ if not spec_path or not os.path.isfile(spec_path): return [] - with open(spec_path) as f: + with open(spec_path, encoding='utf-8') as f: content = f.read() proof_section_match = re.search( r'^## Proof\s*\n(.*?)(?=^## |\Z)', @@ -695,7 +885,7 @@ def check_proof_file(proof_json_path, spec_path=None): if not os.path.isfile(proof_json_path): return [] - with open(proof_json_path) as f: + with open(proof_json_path, encoding='utf-8') as f: data = json.load(f) proofs = data.get('proofs', []) @@ -758,7 +948,7 @@ def read_audit_cache(project_root): cache_path = os.path.join(project_root, '.purlin', 'cache', 'audit_cache.json') if os.path.isfile(cache_path): try: - with open(cache_path) as f: + with open(cache_path, encoding='utf-8') as f: data = json.load(f) if not isinstance(data, dict): return {} @@ -768,6 +958,39 @@ def read_audit_cache(project_root): return {} +def _lock_exclusive(lock_file): + """Acquire an exclusive, blocking lock on an open file handle. + + POSIX uses fcntl.flock; Windows uses msvcrt.locking on a 1-byte region. + On Windows, msvcrt.locking with LK_LOCK gives up after ~10s under + contention, so we retry to match flock's indefinite-block semantics. + """ + if _HAS_FCNTL: + fcntl.flock(lock_file, fcntl.LOCK_EX) + return + import msvcrt + # Ensure a byte exists at offset 0 to lock, then block until it is ours. + lock_file.write('\0') + lock_file.flush() + lock_file.seek(0) + while True: + try: + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + return + except OSError: + continue + + +def _unlock(lock_file): + """Release a lock acquired by _lock_exclusive.""" + if _HAS_FCNTL: + fcntl.flock(lock_file, fcntl.LOCK_UN) + return + import msvcrt + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + + def write_audit_cache(project_root, cache): """Merge new entries into audit cache atomically, pruning stale duplicates. @@ -788,8 +1011,8 @@ def write_audit_cache(project_root, cache): cache_path = os.path.join(cache_dir, 'audit_cache.json') lock_path = cache_path + '.lock' - with open(lock_path, 'w') as lock_file: - fcntl.flock(lock_file, fcntl.LOCK_EX) + with open(lock_path, 'w', encoding='utf-8') as lock_file: + _lock_exclusive(lock_file) try: now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() @@ -836,11 +1059,11 @@ def write_audit_cache(project_root, cache): pruned[hk] = ent tmp_path = cache_path + '.tmp' - with open(tmp_path, 'w') as f: + with open(tmp_path, 'w', encoding='utf-8') as f: json.dump(pruned, f, indent=2) os.replace(tmp_path, cache_path) finally: - fcntl.flock(lock_file, fcntl.LOCK_UN) + _unlock(lock_file) def _find_plugin_root(): @@ -871,20 +1094,20 @@ def load_criteria(project_root, extra_path=None): builtin_path = os.path.join(plugin_root, 'references', 'audit_criteria.md') if not os.path.isfile(builtin_path): return '' - with open(builtin_path) as f: + with open(builtin_path, encoding='utf-8') as f: criteria = f.read() # 2. Check for cached additional criteria (saved by purlin:init --sync-audit-criteria) cached_path = os.path.join(project_root, '.purlin', 'cache', 'additional_criteria.md') if os.path.isfile(cached_path): - with open(cached_path) as f: + with open(cached_path, encoding='utf-8') as f: additional = f.read() # Read source URL from config for the separator header source = 'team criteria' config_path = os.path.join(project_root, '.purlin', 'config.json') if os.path.isfile(config_path): try: - with open(config_path) as f: + with open(config_path, encoding='utf-8') as f: config = json.load(f) source = config.get('audit_criteria', source) except (json.JSONDecodeError, OSError): @@ -893,7 +1116,7 @@ def load_criteria(project_root, extra_path=None): # 3. Append extra file if provided (--criteria flag) if extra_path and os.path.isfile(extra_path): - with open(extra_path) as f: + with open(extra_path, encoding='utf-8') as f: extra = f.read() criteria += f"\n\n---\n\n## Additional Criteria (from {extra_path})\n\n{extra}" @@ -906,7 +1129,7 @@ def clear_audit_cache(project_root): os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, 'audit_cache.json') tmp_path = cache_path + '.tmp' - with open(tmp_path, 'w') as f: + with open(tmp_path, 'w', encoding='utf-8') as f: json.dump({}, f) os.replace(tmp_path, cache_path) return cache_path @@ -929,7 +1152,7 @@ def prune_audit_cache(project_root, live_keys): os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, 'audit_cache.json') tmp_path = cache_path + '.tmp' - with open(tmp_path, 'w') as f: + with open(tmp_path, 'w', encoding='utf-8') as f: json.dump(pruned, f, indent=2) os.replace(tmp_path, cache_path) @@ -940,7 +1163,20 @@ def prune_audit_cache(project_root, live_keys): # Main # --------------------------------------------------------------------------- +def _force_utf8_stdio(): + """Reconfigure stdout/stderr to UTF-8 so output containing non-ASCII + characters (criteria glyphs like ✓/⚠) prints regardless of the OS console + codec (cp1252 / ASCII on Windows would otherwise raise UnicodeEncodeError). + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding='utf-8') + except (AttributeError, ValueError): + pass # not a reconfigurable text stream (e.g. captured/replaced) + + def main(): + _force_utf8_stdio() # --load-criteria mode: output combined criteria (built-in + additional) if '--load-criteria' in sys.argv: project_root = os.getcwd() @@ -1025,7 +1261,7 @@ def main(): if not live_keys_file or not os.path.isfile(live_keys_file): print(json.dumps({'error': '--prune-cache requires --live-keys-file '})) sys.exit(2) - with open(live_keys_file) as f: + with open(live_keys_file, encoding='utf-8') as f: live_keys = set(line.strip() for line in f if line.strip()) result = prune_audit_cache(project_root, live_keys) print(json.dumps(result)) @@ -1085,16 +1321,7 @@ def main(): sys.exit(2) rule_descs = _read_rule_descriptions(spec_path) - ext = os.path.splitext(test_file)[1].lower() - - if ext == '.py': - results = check_python(test_file, feature_name, rule_descs) - elif ext == '.sh': - results = check_shell(test_file, feature_name) - elif ext in ('.js', '.ts', '.jsx', '.tsx'): - results = check_js(test_file, feature_name) - else: - results = [] + results = analyze_test_file(test_file, feature_name, rule_descs) output = {'proofs': results} print(json.dumps(output, indent=2)) diff --git a/skills/audit/SKILL.md b/skills/audit/SKILL.md index 3662ed60..670dfce0 100644 --- a/skills/audit/SKILL.md +++ b/skills/audit/SKILL.md @@ -21,6 +21,8 @@ Load combined criteria via the single-source function: python3 ${CLAUDE_PLUGIN_ROOT}/scripts/audit/static_checks.py --load-criteria --project-root ``` +**Interpreter:** every `static_checks.py` invocation below is written as `python3`, but `python3` is not always on PATH (notably on Windows, where the launcher is `python` or `py -3`). Probe for an available interpreter and use the first that resolves — `python3`, then `python`, then `py -3` — for all `static_checks.py` commands in this skill. + If `--criteria ` was passed by the user, add `--extra ` to append that file too. This returns built-in criteria + any configured additional team criteria + any extra file. Built-in criteria always apply — additional criteria are appended, never replace. diff --git a/specs/audit/static_checks.proofs-e2e.json b/specs/audit/static_checks.proofs-e2e.json index 6bc24ced..c1daf114 100644 --- a/specs/audit/static_checks.proofs-e2e.json +++ b/specs/audit/static_checks.proofs-e2e.json @@ -81,6 +81,24 @@ "test_name": "test_write_cache_stamps_real_utc_time", "status": "pass", "tier": "e2e" + }, + { + "feature": "static_checks", + "id": "PROOF-51", + "rule": "RULE-29", + "test_file": "dev/test_e2e_audit_cache_pipeline.py", + "test_name": "test_cli_pipeline_subprocess", + "status": "pass", + "tier": "e2e" + }, + { + "feature": "static_checks", + "id": "PROOF-52", + "rule": "RULE-30", + "test_file": "dev/test_e2e_audit_cache_pipeline.py", + "test_name": "test_load_criteria_under_ascii_locale", + "status": "pass", + "tier": "e2e" } ] } diff --git a/specs/audit/static_checks.proofs-integration.json b/specs/audit/static_checks.proofs-integration.json index 3a15a7c7..35cf943e 100644 --- a/specs/audit/static_checks.proofs-integration.json +++ b/specs/audit/static_checks.proofs-integration.json @@ -1,6 +1,15 @@ { "tier": "integration", "proofs": [ + { + "feature": "static_checks", + "id": "PROOF-50", + "rule": "RULE-29", + "test_file": "dev/test_e2e_audit_cache_pipeline.py", + "test_name": "test_windows_lock_path_via_fake_msvcrt", + "status": "pass", + "tier": "integration" + }, { "feature": "static_checks", "id": "PROOF-15", diff --git a/specs/audit/static_checks.proofs-unit.json b/specs/audit/static_checks.proofs-unit.json index 699a1223..b4931263 100644 --- a/specs/audit/static_checks.proofs-unit.json +++ b/specs/audit/static_checks.proofs-unit.json @@ -567,6 +567,60 @@ "test_name": "test_write_cache_cli_merges_with_existing", "status": "pass", "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-44", + "rule": "RULE-29", + "test_file": "dev/test_static_checks.py", + "test_name": "test_no_unconditional_fcntl_import", + "status": "pass", + "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-45", + "rule": "RULE-30", + "test_file": "dev/test_static_checks.py", + "test_name": "test_all_text_open_calls_specify_utf8", + "status": "pass", + "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-46", + "rule": "RULE-31", + "test_file": "dev/test_static_checks.py", + "test_name": "test_detects_assert_true", + "status": "pass", + "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-47", + "rule": "RULE-31", + "test_file": "dev/test_static_checks.py", + "test_name": "test_detects_no_assertions", + "status": "pass", + "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-48", + "rule": "RULE-31", + "test_file": "dev/test_static_checks.py", + "test_name": "test_recognizes_all_assertion_frameworks", + "status": "pass", + "tier": "unit" + }, + { + "feature": "static_checks", + "id": "PROOF-49", + "rule": "RULE-31", + "test_file": "dev/test_static_checks.py", + "test_name": "test_dispatch_routes_cs_to_check_csharp", + "status": "pass", + "tier": "unit" } ] } diff --git a/specs/skills/skill_audit.proofs-unit.json b/specs/skills/skill_audit.proofs-unit.json index 443f7f73..099dbe71 100644 --- a/specs/skills/skill_audit.proofs-unit.json +++ b/specs/skills/skill_audit.proofs-unit.json @@ -27,6 +27,15 @@ "test_name": "test_name_matches_directory", "status": "pass", "tier": "unit" + }, + { + "feature": "skill_audit", + "id": "PROOF-15", + "rule": "RULE-15", + "test_file": "dev/test_skill_specs.py", + "test_name": "test_documents_portable_interpreter_fallback", + "status": "pass", + "tier": "unit" } ] } From c66e87626c6a40e65bd9683e2d48fbe849d22122 Mon Sep 17 00:00:00 2001 From: rlabarca Date: Wed, 24 Jun 2026 16:18:43 -0400 Subject: [PATCH 3/5] spec(static_checks): broaden RULE-30 to stdout UTF-8 reconfigure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the fix surfaced a third Windows failure mode: --load-criteria prints criteria glyphs (✓/⚠) to an ASCII stdout and dies with UnicodeEncodeError even after the read-side encoding fix. RULE-30 now covers all text I/O — open() encoding AND stdout/stderr reconfigure — and PROOF-52 reproduces it via --load-criteria under PYTHONUTF8=0/LC_ALL=C. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/audit/static_checks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/audit/static_checks.md b/specs/audit/static_checks.md index 2c1829e3..6af03e6e 100644 --- a/specs/audit/static_checks.md +++ b/specs/audit/static_checks.md @@ -32,7 +32,7 @@ - RULE-27: check_js detects tautological assertions (`expect(true).toBe(true)`) and JS/TS test bodies with no `expect()` calls, returning the same JSON shape (proof_id, rule_id, test_name, status, reason) as check_python - RULE-28: check_js parses JS/TS test files with a brace-balancing tokenizer that (a) matches test titles containing apostrophes regardless of quote style, and (b) captures full test bodies containing nested braces — options objects, destructured parameters, type assertions — without truncating at the first inner `}` - RULE-29: static_checks.py imports and runs on Windows as well as POSIX — no Unix-only module is imported unconditionally at module load. `fcntl` is imported under `try/except ImportError` and a module-level flag (`_HAS_FCNTL`) selects between `fcntl.flock` and an `msvcrt.locking` fallback, so write_audit_cache acquires and releases its exclusive lock through that flag and the Windows lock path is exercisable without `fcntl` -- RULE-30: Every text-mode `open()` in static_checks.py specifies `encoding='utf-8'`, so the tool's own UTF-8 specs, proofs, criteria, config, and cache files parse and write identically regardless of the OS locale codec (e.g. cp1252 on Windows) +- RULE-30: static_checks.py performs all text I/O as UTF-8 regardless of the OS locale: every text-mode `open()` specifies `encoding='utf-8'`, and stdout/stderr are reconfigured to UTF-8 at startup, so the tool's own UTF-8 specs/proofs/criteria/config/cache files both parse (read) and print (stdout) correctly even when the platform codec is cp1252/ASCII (e.g. on Windows). Without this, `--load-criteria` raises `UnicodeDecodeError` reading `audit_criteria.md` and `UnicodeEncodeError` printing its glyphs - RULE-31: For `.cs` test files, Pass 1 parses `[Trait("PurlinProof", "feature:PROOF-N:RULE-N:tier")]` traits, associates each with its `[Fact]`/`[Theory]` method body, and applies assert-true and no-assertion detection — returning the same JSON shape (proof_id, rule_id, test_name, status, reason) as check_python/check_js. xUnit `Assert.*`, NUnit `Assert.That`, MSTest `Assert.*`, and FluentAssertions `.Should()` all count as assertions ## Proof @@ -86,4 +86,4 @@ - PROOF-49 (RULE-31): Run the static_checks main dispatch (by file extension) on a `.cs` test file; verify it routes to check_csharp and returns a non-empty proofs array rather than the empty-`[]` fallback for unknown extensions - PROOF-50 (RULE-29): With `fcntl` forced unavailable (`_HAS_FCNTL=False`) and a recording fake `msvcrt` injected into `sys.modules`, call write_audit_cache; verify the cache round-trips intact AND the fake `msvcrt.locking` was invoked with `LK_LOCK` then `LK_UNLCK` @integration - PROOF-51 (RULE-29): e2e: Invoke the real static_checks.py CLI as a subprocess on the host (`--write-cache` via stdin, then `--read-cache`, then Pass-1 on a hollow `.cs` fixture); verify valid JSON is emitted on stdout at each step and `.purlin/cache/audit_cache.json` is written to disk @e2e -- PROOF-52 (RULE-30): e2e: Invoke the real static_checks.py CLI as a subprocess with `env PYTHONUTF8=0` and `LC_ALL=C` (simulating the Windows cp1252 default), pointing it at a UTF-8 file containing a non-ASCII byte; verify it completes without `UnicodeDecodeError` and emits valid JSON @e2e +- PROOF-52 (RULE-30): e2e: Invoke the real static_checks.py CLI `--load-criteria` as a subprocess with `env PYTHONUTF8=0`, `LC_ALL=C`, and `LANG=C` (simulating the Windows cp1252/ASCII default), reading the tool's own `references/audit_criteria.md` which contains non-ASCII glyphs; verify it exits 0 (no `UnicodeDecodeError` on read, no `UnicodeEncodeError` on stdout) and the printed criteria text contains a non-ASCII character @e2e From 8a392987f558e504209d8422b52a4baf3b051fc8 Mon Sep 17 00:00:00 2001 From: rlabarca Date: Wed, 24 Jun 2026 16:18:43 -0400 Subject: [PATCH 4/5] verify: [Complete:static_checks,skill_audit] features=2/2 vhash=bb1c6eaf Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/audit/static_checks.receipt.json | 286 +++++++++++++++---------- specs/skills/skill_audit.receipt.json | 64 +++--- 2 files changed, 202 insertions(+), 148 deletions(-) diff --git a/specs/audit/static_checks.receipt.json b/specs/audit/static_checks.receipt.json index 900f055a..c1a14699 100644 --- a/specs/audit/static_checks.receipt.json +++ b/specs/audit/static_checks.receipt.json @@ -1,8 +1,8 @@ { "feature": "static_checks", - "vhash": "fe40cdeb", - "commit": "85b09702318edd3da649d7d95473bc147a6c381c", - "timestamp": "2026-05-23T21:00:50.391930+00:00", + "vhash": "268ba07c", + "commit": "2259011e512dffa219356892dc1187651a58b6ba", + "timestamp": "2026-06-24T20:17:54.345616+00:00", "rules": [ "RULE-1", "RULE-10", @@ -24,7 +24,10 @@ "RULE-26", "RULE-27", "RULE-28", + "RULE-29", "RULE-3", + "RULE-30", + "RULE-31", "RULE-4", "RULE-5", "RULE-6", @@ -32,6 +35,61 @@ "RULE-8" ], "proofs": [ + { + "id": "PROOF-42", + "rule": "RULE-27", + "status": "pass" + }, + { + "id": "PROOF-43", + "rule": "RULE-28", + "status": "pass" + }, + { + "id": "PROOF-28", + "rule": "RULE-12", + "status": "pass" + }, + { + "id": "PROOF-29", + "rule": "RULE-17", + "status": "pass" + }, + { + "id": "PROOF-30", + "rule": "RULE-24", + "status": "pass" + }, + { + "id": "PROOF-31", + "rule": "RULE-24", + "status": "pass" + }, + { + "id": "PROOF-32", + "rule": "RULE-18", + "status": "pass" + }, + { + "id": "PROOF-38", + "rule": "RULE-22", + "status": "pass" + }, + { + "id": "PROOF-33", + "rule": "RULE-19", + "status": "pass" + }, + { + "id": "PROOF-51", + "rule": "RULE-29", + "status": "pass" + }, + { + "id": "PROOF-52", + "rule": "RULE-30", + "status": "pass" + }, { "id": "PROOF-1", "rule": "RULE-1", @@ -47,6 +105,71 @@ "rule": "RULE-1", "status": "pass" }, + { + "id": "PROOF-2", + "rule": "RULE-2", + "status": "pass" + }, + { + "id": "PROOF-3", + "rule": "RULE-3", + "status": "pass" + }, + { + "id": "PROOF-3", + "rule": "RULE-3", + "status": "pass" + }, + { + "id": "PROOF-4", + "rule": "RULE-4", + "status": "pass" + }, + { + "id": "PROOF-4", + "rule": "RULE-4", + "status": "pass" + }, + { + "id": "PROOF-5", + "rule": "RULE-5", + "status": "pass" + }, + { + "id": "PROOF-5", + "rule": "RULE-5", + "status": "pass" + }, + { + "id": "PROOF-6", + "rule": "RULE-6", + "status": "pass" + }, + { + "id": "PROOF-7", + "rule": "RULE-7", + "status": "pass" + }, + { + "id": "PROOF-7", + "rule": "RULE-7", + "status": "pass" + }, + { + "id": "PROOF-8", + "rule": "RULE-8", + "status": "pass" + }, + { + "id": "PROOF-8", + "rule": "RULE-8", + "status": "pass" + }, + { + "id": "PROOF-8", + "rule": "RULE-8", + "status": "pass" + }, { "id": "PROOF-10", "rule": "RULE-10", @@ -87,6 +210,31 @@ "rule": "RULE-12", "status": "pass" }, + { + "id": "PROOF-39", + "rule": "RULE-24", + "status": "pass" + }, + { + "id": "PROOF-39", + "rule": "RULE-24", + "status": "pass" + }, + { + "id": "PROOF-36", + "rule": "RULE-22", + "status": "pass" + }, + { + "id": "PROOF-37", + "rule": "RULE-23", + "status": "pass" + }, + { + "id": "PROOF-37", + "rule": "RULE-23", + "status": "pass" + }, { "id": "PROOF-13", "rule": "RULE-13", @@ -162,11 +310,6 @@ "rule": "RULE-15", "status": "pass" }, - { - "id": "PROOF-15", - "rule": "RULE-15", - "status": "pass" - }, { "id": "PROOF-16", "rule": "RULE-16", @@ -217,51 +360,6 @@ "rule": "RULE-16", "status": "pass" }, - { - "id": "PROOF-2", - "rule": "RULE-2", - "status": "pass" - }, - { - "id": "PROOF-28", - "rule": "RULE-12", - "status": "pass" - }, - { - "id": "PROOF-29", - "rule": "RULE-17", - "status": "pass" - }, - { - "id": "PROOF-3", - "rule": "RULE-3", - "status": "pass" - }, - { - "id": "PROOF-3", - "rule": "RULE-3", - "status": "pass" - }, - { - "id": "PROOF-30", - "rule": "RULE-24", - "status": "pass" - }, - { - "id": "PROOF-31", - "rule": "RULE-24", - "status": "pass" - }, - { - "id": "PROOF-32", - "rule": "RULE-18", - "status": "pass" - }, - { - "id": "PROOF-33", - "rule": "RULE-19", - "status": "pass" - }, { "id": "PROOF-35", "rule": "RULE-21", @@ -287,46 +385,6 @@ "rule": "RULE-21", "status": "pass" }, - { - "id": "PROOF-36", - "rule": "RULE-22", - "status": "pass" - }, - { - "id": "PROOF-37", - "rule": "RULE-23", - "status": "pass" - }, - { - "id": "PROOF-37", - "rule": "RULE-23", - "status": "pass" - }, - { - "id": "PROOF-38", - "rule": "RULE-22", - "status": "pass" - }, - { - "id": "PROOF-39", - "rule": "RULE-24", - "status": "pass" - }, - { - "id": "PROOF-39", - "rule": "RULE-24", - "status": "pass" - }, - { - "id": "PROOF-4", - "rule": "RULE-4", - "status": "pass" - }, - { - "id": "PROOF-4", - "rule": "RULE-4", - "status": "pass" - }, { "id": "PROOF-40", "rule": "RULE-25", @@ -348,53 +406,43 @@ "status": "pass" }, { - "id": "PROOF-42", - "rule": "RULE-27", - "status": "pass" - }, - { - "id": "PROOF-43", - "rule": "RULE-28", - "status": "pass" - }, - { - "id": "PROOF-5", - "rule": "RULE-5", + "id": "PROOF-44", + "rule": "RULE-29", "status": "pass" }, { - "id": "PROOF-5", - "rule": "RULE-5", + "id": "PROOF-45", + "rule": "RULE-30", "status": "pass" }, { - "id": "PROOF-6", - "rule": "RULE-6", + "id": "PROOF-46", + "rule": "RULE-31", "status": "pass" }, { - "id": "PROOF-7", - "rule": "RULE-7", + "id": "PROOF-47", + "rule": "RULE-31", "status": "pass" }, { - "id": "PROOF-7", - "rule": "RULE-7", + "id": "PROOF-48", + "rule": "RULE-31", "status": "pass" }, { - "id": "PROOF-8", - "rule": "RULE-8", + "id": "PROOF-49", + "rule": "RULE-31", "status": "pass" }, { - "id": "PROOF-8", - "rule": "RULE-8", + "id": "PROOF-50", + "rule": "RULE-29", "status": "pass" }, { - "id": "PROOF-8", - "rule": "RULE-8", + "id": "PROOF-15", + "rule": "RULE-15", "status": "pass" } ] diff --git a/specs/skills/skill_audit.receipt.json b/specs/skills/skill_audit.receipt.json index dd3c2ed5..01048e04 100644 --- a/specs/skills/skill_audit.receipt.json +++ b/specs/skills/skill_audit.receipt.json @@ -1,8 +1,8 @@ { "feature": "skill_audit", - "vhash": "2eb3ab10", - "commit": "6cd4518e322d351ea3c91a7b877f1e59f90f2492", - "timestamp": "2026-04-07T13:46:48.452182+00:00", + "vhash": "5bd9ab85", + "commit": "2259011e512dffa219356892dc1187651a58b6ba", + "timestamp": "2026-06-24T20:17:54.345616+00:00", "rules": [ "RULE-1", "RULE-10", @@ -10,6 +10,7 @@ "RULE-12", "RULE-13", "RULE-14", + "RULE-15", "RULE-2", "RULE-3", "RULE-4", @@ -25,31 +26,6 @@ "rule": "RULE-1", "status": "pass" }, - { - "id": "PROOF-10", - "rule": "RULE-10", - "status": "pass" - }, - { - "id": "PROOF-11", - "rule": "RULE-11", - "status": "pass" - }, - { - "id": "PROOF-12", - "rule": "RULE-12", - "status": "pass" - }, - { - "id": "PROOF-13", - "rule": "RULE-13", - "status": "pass" - }, - { - "id": "PROOF-14", - "rule": "RULE-14", - "status": "pass" - }, { "id": "PROOF-2", "rule": "RULE-2", @@ -60,6 +36,11 @@ "rule": "RULE-3", "status": "pass" }, + { + "id": "PROOF-15", + "rule": "RULE-15", + "status": "pass" + }, { "id": "PROOF-4", "rule": "RULE-4", @@ -89,6 +70,31 @@ "id": "PROOF-9", "rule": "RULE-9", "status": "pass" + }, + { + "id": "PROOF-10", + "rule": "RULE-10", + "status": "pass" + }, + { + "id": "PROOF-11", + "rule": "RULE-11", + "status": "pass" + }, + { + "id": "PROOF-12", + "rule": "RULE-12", + "status": "pass" + }, + { + "id": "PROOF-13", + "rule": "RULE-13", + "status": "pass" + }, + { + "id": "PROOF-14", + "rule": "RULE-14", + "status": "pass" } ] -} \ No newline at end of file +} From 102a5e177204cce1b5493cf1162d3beceac0448d Mon Sep 17 00:00:00 2001 From: rlabarca Date: Wed, 24 Jun 2026 16:24:12 -0400 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v0.9.5=20=E2=80=94=20cross-platf?= =?UTF-8?q?orm=20audit=20Pass-1=20(Windows)=20&=20C#=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump VERSION and templates/config.json to 0.9.5. Fixes #3: purlin:audit now runs on Windows (fcntl optional + msvcrt lock fallback, UTF-8 text I/O, UTF-8 stdout) and gains deterministic C#/.NET Pass-1 coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- RELEASE_NOTES.md | 18 ++++++++++++++++++ VERSION | 2 +- templates/config.json | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3a53768..9e3240ff 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/VERSION b/VERSION index a602fc9e..b0bb8785 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.4 +0.9.5 diff --git a/templates/config.json b/templates/config.json index bf548804..8b8701cf 100644 --- a/templates/config.json +++ b/templates/config.json @@ -1,5 +1,5 @@ { - "version": "0.9.4", + "version": "0.9.5", "test_framework": "auto", "spec_dir": "specs", "pre_push": "warn",