From 63e78f79e385b0a66e5c48dee7e5c6e976b68aa5 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 11:31:49 +0200 Subject: [PATCH 1/7] test(path-guards): assert behaviour on the untested branches of the three survivor modules Issue #74's 136 pre-existing mutants concentrate on branches these suites never asserted through: wiki_read.py's list/project/bibliography readers, the static/shared-asset response headers and content-type table, and the file-diff resolver's exception and activity-store-lookup arms. Adds behaviour-derived tests for each, reasoned from the contract (not from mutmut's suggestions, since a shared-machine mutation campaign is on hold for this commit pending a neighbouring measurement session): - wiki_read: _parse_list's three raw shapes, _title_from's fallback ladder, _page_item's field precedence and OSError arm, _iter_md's bibliography skip, list_pages/list_projects/list_bibliography's empty-root and grouping/sorting/counting contracts, read_bibliography's content+size, save_page's UTF-8 byte accounting (distinct from character length). - http_standalone_static: response framing headers (Content-Type, Content-Length, Cache-Control) on both readers, the shared-asset extension-to-content-type table (every mapped extension plus the text/plain fallback, case-insensitively), the serve_static filename regex's first-character boundary, and the directory-vs-file distinction in the whitelist. - http_file_diff: the activity-store lookup-exception arms for both the basename and suffix-search resolvers, the suffix-search fallback and its precedence below a known-repo match, and serve_file_diff itself (missing name, unresolvable name, successful delegation to the diff engine, and the top-level exception-to-500 arm). 139 tests now pass across the three modules (was 116); full suite (1289 tests) is green. Mutation-count verification is queued for when the shared machine clears (issue #74 comment). Co-Authored-By: Claude Opus 5 --- tests/test_file_diff.py | 201 +++++++++++++++++++++ tests/test_static_path_traversal.py | 118 +++++++++++++ tests/test_wiki_read.py | 262 ++++++++++++++++++++++++++++ 3 files changed, 581 insertions(+) diff --git a/tests/test_file_diff.py b/tests/test_file_diff.py index 57ecbb3..213cfce 100644 --- a/tests/test_file_diff.py +++ b/tests/test_file_diff.py @@ -104,3 +104,204 @@ def test_resolve_by_relative_fragment_rejects_path_traversal(): abs_path, reason = _resolve_by_relative_fragment(None, "../../etc/passwd") assert abs_path is None assert reason == "unresolved relative name: path traversal rejected" + + +# ── _resolve_by_basename / _resolve_by_relative_fragment failure arms ──── +# The happy paths and the "store unavailable" arms are covered via +# ``_resolve_name`` in test_git_diff_engine.py; these pin the two arms only +# reachable when a store IS present: the activity-store lookup raising, and +# (for the relative-fragment resolver) the suffix-search fallback. + + +def test_resolve_by_basename_store_lookup_exception_reports_reason(monkeypatch): + import cortex_viz.infrastructure.activity_store as activity_store + from cortex_viz.server.http_file_diff import _resolve_by_basename + + def _boom(store, label): + raise RuntimeError("db down") + + monkeypatch.setattr(activity_store, "find_abs_path_by_label", _boom) + abs_path, reason = _resolve_by_basename(object(), "foo.py") + assert abs_path is None + assert reason == "unresolved basename: activity store lookup failed" + + +def test_resolve_by_relative_fragment_store_none_after_no_repo_match_reports_reason(): + # No registry repo matches and store is None -- distinct message from + # the "no such basename" and "activity store lookup failed" arms. + abs_path, reason = _resolve_by_relative_fragment(None, "no/such/repo/file.py") + assert abs_path is None + assert reason == "unresolved relative name: not found in known repos" + + +def test_resolve_by_relative_fragment_falls_back_to_activity_suffix_search( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment + + monkeypatch.setattr( + activity_store, + "find_abs_path_by_suffix", + lambda store, name: "/repo/src/found.py" if name == "src/found.py" else None, + ) + abs_path, reason = _resolve_by_relative_fragment(object(), "src/found.py") + assert abs_path == "/repo/src/found.py" + assert reason is None + + +def test_resolve_by_relative_fragment_suffix_search_not_found_reports_reason( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment + + monkeypatch.setattr( + activity_store, "find_abs_path_by_suffix", lambda store, name: None + ) + abs_path, reason = _resolve_by_relative_fragment(object(), "src/missing.py") + assert abs_path is None + assert reason == "unresolved relative name: not found in activity index or known repos" + + +def test_resolve_by_relative_fragment_suffix_search_exception_reports_reason( + monkeypatch, +): + import cortex_viz.infrastructure.activity_store as activity_store + from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment + + def _boom(store, name): + raise RuntimeError("db down") + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) + abs_path, reason = _resolve_by_relative_fragment(object(), "src/anything.py") + assert abs_path is None + assert reason == "unresolved relative name: activity store lookup failed" + + +def test_resolve_by_relative_fragment_prefers_known_repo_over_suffix_search( + monkeypatch, tmp_path +): + # When a repo-root candidate exists on disk, the suffix-search fallback + # (and its store) must never even be consulted. + import cortex_viz.infrastructure.activity_store as activity_store + + repo_dir = tmp_path / "known_repo" + (repo_dir / "src").mkdir(parents=True) + (repo_dir / "src" / "file.py").write_text("x\n") + + class _FakeRepoInfo: + def __init__(self, fs_path: str) -> None: + self.fs_path = fs_path + + class _FakeRegistry: + repos = [_FakeRepoInfo(str(repo_dir))] + + monkeypatch.setattr( + "cortex_viz.shared.domain_mapping._build_registry", lambda: _FakeRegistry() + ) + + def _boom(store, name): # pragma: no cover - must never run + raise AssertionError("suffix search must not run when a repo root matches") + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) + abs_path, reason = _resolve_by_relative_fragment(object(), "src/file.py") + assert abs_path == str(repo_dir / "src" / "file.py") + assert reason is None + + +# ── serve_file_diff — the HTTP entry point itself ──────────────────────── + + +class _FakeHandler: + def __init__(self, path: str) -> None: + self.path = path + self.status: int | None = None + self.headers: dict[str, str] = {} + self.body = b"" + self.wfile = self + + def send_response(self, code: int) -> None: + self.status = code + + def send_header(self, k: str, v: str) -> None: + self.headers[k] = v + + def end_headers(self) -> None: + pass + + def write(self, data: bytes) -> None: + self.body += data + + +def _decode_json(handler: _FakeHandler) -> dict: + import json + + return json.loads(handler.body.decode("utf-8")) + + +def test_serve_file_diff_missing_name_param_reports_no_file_given(): + import cortex_viz.server.http_file_diff as mod_local + + h = _FakeHandler("/api/file-diff") + mod_local.serve_file_diff(h) + assert h.status == 200 + payload = _decode_json(h) + assert payload == { + "available": False, + "diff_type": "none", + "lines": [], + "truncated": False, + "reason": "no file given", + } + + +def test_serve_file_diff_unresolvable_name_reports_reason_and_unavailable(): + import cortex_viz.server.http_file_diff as mod_local + + h = _FakeHandler("/api/file-diff?name=nowhere.py") + mod_local.serve_file_diff(h, store=None) + assert h.status == 200 + payload = _decode_json(h) + assert payload["available"] is False + assert payload["diff_type"] == "none" + assert payload["reason"] == "unresolved basename: activity store unavailable" + + +def test_serve_file_diff_resolves_absolute_path_and_delegates_to_the_engine( + tmp_path: Path, +): + import subprocess + + import cortex_viz.server.http_file_diff as mod_local + + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "-C", str(root), "init", "-q"], check=True) + (root / "new.txt").write_text("alpha\n") + + h = _FakeHandler(f"/api/file-diff?name={root / 'new.txt'}") + mod_local.serve_file_diff(h) + assert h.status == 200 + payload = _decode_json(h) + assert payload["available"] is True + assert payload["diff_type"] == "untracked" + + +def test_serve_file_diff_unexpected_exception_yields_json_error(): + import cortex_viz.server.http_file_diff as mod_local + + class _BrokenHandler(_FakeHandler): + @property + def path(self): # noqa: D401 - raising on access forces the except branch + raise RuntimeError("boom") + + @path.setter + def path(self, value): + pass + + h = _BrokenHandler("/api/file-diff?name=x") + mod_local.serve_file_diff(h) + assert h.status == 500 + payload = _decode_json(h) + assert payload == {"error": "RuntimeError"} diff --git a/tests/test_static_path_traversal.py b/tests/test_static_path_traversal.py index 2b15e3b..c958684 100644 --- a/tests/test_static_path_traversal.py +++ b/tests/test_static_path_traversal.py @@ -204,3 +204,121 @@ def test_http_server_static_reader_still_serves_legitimate_files( _serve_static(h, flat_dir, "config.js", "application/javascript") assert h.status == 200 assert h.body == b"// cfg" + + +# ── response framing — headers are part of the contract, not incidental ── +# A body-only assertion cannot distinguish "sent the right Content-Type" from +# "sent an empty/wrong one" -- these pin the framing headers both readers +# promise on a 200. + + +def test_static_success_sends_correct_framing_headers(flat_dir: Path) -> None: + h = FakeHandler() + serve_static(h, flat_dir, "config.js", "application/javascript") + assert h.status == 200 + assert h.headers["Content-Type"] == "application/javascript; charset=utf-8" + assert h.headers["Content-Length"] == str(len(b"// cfg")) + assert h.headers["Cache-Control"] == "no-cache" + + +def test_shared_asset_success_sends_correct_framing_headers(sandbox: Path) -> None: + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens/colors.css") + assert h.status == 200 + body = b"/* colors */" + assert h.headers["Content-Type"] == "text/css; charset=utf-8" + assert h.headers["Content-Length"] == str(len(body)) + assert h.headers["Cache-Control"] == "no-cache" + + +# ── shared-asset content-type table — every mapped extension, plus the +# unmapped fallback, pinned individually (a swapped dict value produces +# a wrong-but-still-served response no status/body assertion catches) ── + + +@pytest.mark.parametrize( + ("filename", "expected_type"), + [ + ("f.css", "text/css"), + ("f.js", "application/javascript"), + ("f.mjs", "application/javascript"), + ("f.json", "application/json"), + ("f.woff2", "font/woff2"), + ("f.woff", "font/woff"), + ("f.ttf", "font/ttf"), + ("f.svg", "image/svg+xml"), + ("f.unknownext", "text/plain"), + ], +) +def test_shared_asset_content_type_table( + tmp_path: Path, filename: str, expected_type: str +) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / filename).write_bytes(b"data") + h = FakeHandler() + serve_shared_asset(h, shared, filename) + assert h.status == 200 + assert h.headers["Content-Type"] == f"{expected_type}; charset=utf-8" + + +def test_shared_asset_content_type_lookup_is_case_insensitive_on_suffix( + tmp_path: Path, +) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / "f.CSS").write_bytes(b"data") + h = FakeHandler() + serve_shared_asset(h, shared, "f.CSS") + assert h.status == 200 + assert h.headers["Content-Type"] == "text/css; charset=utf-8" + + +# ── serve_static filename-whitelist regex — each admitted/rejected class +# of first character and body character, pinned independently ──────── + + +@pytest.mark.parametrize( + ("filename", "should_serve"), + [ + ("123.js", True), # digit-led name is a legal identifier + ("_private.js", True), # underscore-led name is a legal identifier + ("-leading-dash.js", False), # '-' is not \w -- rejected as first char + ("a b.js", False), # embedded space is not in the allowed class + ], +) +def test_static_filename_whitelist_boundary( + tmp_path: Path, filename: str, should_serve: bool +) -> None: + d = tmp_path / "js" + d.mkdir() + (d / filename).write_text("x", encoding="utf-8") + h = FakeHandler() + serve_static(h, d, filename, "application/javascript") + if should_serve: + assert h.status == 200 + assert h.body == b"x" + else: + assert h.status == 403 + assert h.body == b"" + + +def test_static_directory_entry_is_not_served(tmp_path: Path) -> None: + """A directory sharing a name with a would-be file must 404, not be + treated as content -- the whitelist is built with ``is_file()`` only.""" + d = tmp_path / "js" + d.mkdir() + (d / "sub").mkdir() + h = FakeHandler() + serve_static(h, d, "sub", "application/javascript") + assert h.status == 404 + assert h.body == b"" + + +def test_static_unknown_filename_is_404_not_403(flat_dir: Path) -> None: + """A name that passes the regex whitelist but names nothing on disk is a + plain 404 (unknown), distinct from a 403 (rejected shape).""" + h = FakeHandler() + serve_static(h, flat_dir, "does-not-exist.js", "application/javascript") + assert h.status == 404 + assert h.body == b"" diff --git a/tests/test_wiki_read.py b/tests/test_wiki_read.py index 8675453..145d369 100644 --- a/tests/test_wiki_read.py +++ b/tests/test_wiki_read.py @@ -7,6 +7,8 @@ from __future__ import annotations +from pathlib import Path + import pytest import cortex_viz.infrastructure.wiki_read as mod @@ -192,3 +194,263 @@ def test_read_bibliography_refuses_a_path_outside_the_wiki_root(monkeypatch, tmp (tmp_path / "outside.bib").write_text("@book{x}\n", encoding="utf-8") monkeypatch.setattr(mod, "WIKI_ROOT", root) assert mod.read_bibliography("../outside.bib") == {"error": "invalid path"} + + +# ── _parse_list ────────────────────────────────────────────────────────── +# Frontmatter list values arrive in three raw shapes from the flat-KV +# parser: an already-materialised list (only via direct dict construction, +# never from the parser itself, but the function must still honour it), +# a bracketed comma-separated string, and a bare comma-separated string. + + +def test_parse_list_passes_through_a_real_list(): + assert mod._parse_list(["a", " b ", ""]) == ["a", "b"] + + +def test_parse_list_falsy_value_is_empty(): + assert mod._parse_list(None) == [] + assert mod._parse_list("") == [] + + +def test_parse_list_strips_brackets_and_splits_on_comma(): + assert mod._parse_list("[a, b, c]") == ["a", "b", "c"] + + +def test_parse_list_without_brackets_still_splits(): + assert mod._parse_list("a, b") == ["a", "b"] + + +def test_parse_list_strips_quotes_from_each_token(): + assert mod._parse_list("['a', \"b\"]") == ["a", "b"] + + +def test_parse_list_drops_whitespace_only_tokens(): + assert mod._parse_list("a, , b") == ["a", "b"] + + +# ── _title_from ────────────────────────────────────────────────────────── + + +def test_title_from_uses_frontmatter_title_when_present(): + assert mod._title_from({"title": "Explicit Title"}, Path("/x/ignored.md")) == ( + "Explicit Title" + ) + + +def test_title_from_falls_back_to_stem_with_separators_as_spaces(): + assert mod._title_from({}, Path("/x/my-cool_page.md")) == "my cool page" + + +def test_title_from_falls_back_to_filename_when_stem_is_all_separators(): + # stem.replace("-", " ").replace("_", " ").strip() == "" for a stem made + # only of separators -- the final fallback is the literal file name. + assert mod._title_from({}, Path("/x/--__.md")) == "--__.md" + + +# ── _page_item / list_pages / list_projects / _iter_md ───────────────── + + +def test_page_item_reads_frontmatter_fields(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page( + root, + "a/b.md", + "---\n" + "title: B Page\n" + "kind: adr\n" + "domain: cortex\n" + "tags: [x, y]\n" + "status: draft\n" + "date: 2026-01-01\n" + "tended: 2026-02-02\n" + "---\n" + "body\n", + ) + item = mod._page_item(root / "a" / "b.md", root) + assert item == { + "path": "a/b.md", + "title": "B Page", + "kind": "adr", + "domain": "cortex", + "tags": ["x", "y"], + "maturity": "draft", + "created": "2026-01-01", + "updated": "2026-02-02", + } + + +def test_page_item_defaults_kind_to_page_when_absent(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "c.md", "no frontmatter\n") + item = mod._page_item(root / "c.md", root) + assert item["kind"] == "page" + assert item["domain"] == "" + assert item["tags"] == [] + assert item["maturity"] == "" + assert item["created"] == "" + assert item["updated"] == "" + + +def test_page_item_prefers_maturity_over_status_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "m.md", "---\nmaturity: stable\nstatus: draft\n---\nbody\n") + item = mod._page_item(root / "m.md", root) + assert item["maturity"] == "stable" + + +def test_page_item_prefers_created_over_date_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "d.md", "---\ncreated: 2026-01-01\ndate: 2020-01-01\n---\nbody\n") + item = mod._page_item(root / "d.md", root) + assert item["created"] == "2026-01-01" + + +def test_page_item_unreadable_file_yields_empty_meta(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = _write_page(root, "u.md", "---\ntitle: X\n---\nbody\n") + + def _boom(*a, **k): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + item = mod._page_item(p, root) + assert item["title"] == "u" # falls back to the filename stem + assert item["kind"] == "page" + + +def test_iter_md_skips_the_bibliography_subtree(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "keep.md", "keep\n") + _write_page(root, "_bibliography/refs.md", "skip me\n") + found = [str(p.relative_to(root)) for p in mod._iter_md(root)] + assert found == ["keep.md"] + + +def test_list_pages_returns_empty_when_root_missing(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path / "does-not-exist") + assert mod.list_pages() == {"pages": []} + + +def test_list_pages_returns_every_page(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "one.md", "---\ntitle: One\n---\nbody\n") + _write_page(root, "two.md", "---\ntitle: Two\n---\nbody\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_pages() + titles = sorted(p["title"] for p in got["pages"]) + assert titles == ["One", "Two"] + + +def test_list_projects_returns_empty_when_root_missing(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path / "does-not-exist") + assert mod.list_projects() == {"projects": []} + + +def test_list_projects_groups_by_domain_with_kind_counts(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "a.md", "---\ndomain: cortex\nkind: adr\n---\nbody\n") + _write_page(root, "b.md", "---\ndomain: cortex\nkind: adr\n---\nbody\n") + _write_page(root, "c.md", "---\ndomain: cortex\nkind: note\n---\nbody\n") + _write_page(root, "d.md", "no frontmatter\n") # domain absent -> _general + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_projects() + by_domain = {p["domain"]: p for p in got["projects"]} + assert by_domain["cortex"]["page_total"] == 3 + assert by_domain["cortex"]["page_counts_by_kind"] == {"adr": 2, "note": 1} + assert by_domain["_general"]["page_total"] == 1 + + +def test_list_projects_sorts_by_descending_page_total(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page(root, "big1.md", "---\ndomain: big\n---\nb\n") + _write_page(root, "big2.md", "---\ndomain: big\n---\nb\n") + _write_page(root, "small.md", "---\ndomain: small\n---\nb\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_projects() + domains_in_order = [p["domain"] for p in got["projects"]] + assert domains_in_order == ["big", "small"] + + +# ── list_bibliography / read_bibliography ─────────────────────────────── + + +def test_list_bibliography_empty_when_dir_missing(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.list_bibliography() == {"files": []} + + +def test_list_bibliography_counts_entries_and_reports_size(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + content = "@book{a,\n title={A}\n}\n@article{b,\n title={B}\n}\n" + (bib_dir / "refs.bib").write_text(content, encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert len(got["files"]) == 1 + entry = got["files"][0] + assert entry["path"] == "_bibliography/refs.bib" + assert entry["entries"] == 2 + assert entry["size"] == len(content.encode("utf-8")) + + +def test_list_bibliography_counts_a_leading_entry_with_no_preceding_newline( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + # The file's very first byte is '@' -- no "\n@" occurs, so the leading + # entry would be undercounted without the lstrip().startswith("@") arm. + (bib_dir / "solo.bib").write_text("@book{a,\n title={A}\n}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 + + +def test_read_bibliography_returns_content_and_byte_size(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + content = "@book{a}\n" + (bib_dir / "refs.bib").write_text(content, encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.read_bibliography("_bibliography/refs.bib") + assert got == {"path": "_bibliography/refs.bib", "content": content, "size": len(content)} + + +def test_read_bibliography_refuses_a_bib_outside_the_bibliography_subtree( + monkeypatch, tmp_path +): + # _safe_path alone would accept this (it's under WIKI_ROOT and ends in + # .bib) -- the "_bibliography" in p.parts guard is a second, independent + # gate that must reject it too. + root = tmp_path / "wiki" + root.mkdir() + (root / "stray.bib").write_text("@book{a}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_bibliography("stray.bib") == {"error": "invalid path"} + + +# ── save_page byte accounting ─────────────────────────────────────────── + + +def test_save_page_reports_utf8_byte_length_not_character_length(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + content = "café" # 4 chars, 5 UTF-8 bytes + got = mod.save_page("page.md", content) + assert got["bytes"] == 5 + assert len(content) == 4 From 620cc8d7b5967c5f45a4c373dc5b8104c7037bb2 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 11:36:17 +0200 Subject: [PATCH 2/7] fix(test): satisfy ruff line-length and RUF012 on the new mutant-killing tests CI's lint job caught two E501 line-length violations and one RUF012 (mutable class-attribute default needing ClassVar) introduced by the previous commit's added tests. Co-Authored-By: Claude Opus 5 --- tests/test_file_diff.py | 7 +++++-- tests/test_wiki_read.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_file_diff.py b/tests/test_file_diff.py index 213cfce..e5d67cd 100644 --- a/tests/test_file_diff.py +++ b/tests/test_file_diff.py @@ -10,6 +10,7 @@ import subprocess from pathlib import Path +from typing import ClassVar from cortex_viz.server.git_diff_engine import ( _MAX_LINES, @@ -161,7 +162,9 @@ def test_resolve_by_relative_fragment_suffix_search_not_found_reports_reason( ) abs_path, reason = _resolve_by_relative_fragment(object(), "src/missing.py") assert abs_path is None - assert reason == "unresolved relative name: not found in activity index or known repos" + assert reason == ( + "unresolved relative name: not found in activity index or known repos" + ) def test_resolve_by_relative_fragment_suffix_search_exception_reports_reason( @@ -195,7 +198,7 @@ def __init__(self, fs_path: str) -> None: self.fs_path = fs_path class _FakeRegistry: - repos = [_FakeRepoInfo(str(repo_dir))] + repos: ClassVar[list] = [_FakeRepoInfo(str(repo_dir))] monkeypatch.setattr( "cortex_viz.shared.domain_mapping._build_registry", lambda: _FakeRegistry() diff --git a/tests/test_wiki_read.py b/tests/test_wiki_read.py index 145d369..b9bf347 100644 --- a/tests/test_wiki_read.py +++ b/tests/test_wiki_read.py @@ -427,7 +427,11 @@ def test_read_bibliography_returns_content_and_byte_size(monkeypatch, tmp_path): (bib_dir / "refs.bib").write_text(content, encoding="utf-8") monkeypatch.setattr(mod, "WIKI_ROOT", root) got = mod.read_bibliography("_bibliography/refs.bib") - assert got == {"path": "_bibliography/refs.bib", "content": content, "size": len(content)} + assert got == { + "path": "_bibliography/refs.bib", + "content": content, + "size": len(content), + } def test_read_bibliography_refuses_a_bib_outside_the_bibliography_subtree( From 6030085dbe9dc6454aa267279ff49a271d1a6a59 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 11:42:59 +0200 Subject: [PATCH 3/7] docs(claude-md): document the uv sync --frozen dev-extras trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered while running the issue #74 mutant-killing suite in a fresh worktree: a bare `uv sync --frozen` leaves pytest/mutmut uninstalled (they live in the `dev` optional-dependency group) and the failure is silent — `uv run pytest` falls through PATH to a system interpreter instead of erroring, so a scoped test run can look green while the full suite silently drops every DB-backed module's collection. Recorded here so the next worktree doesn't rediscover it the hard way. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bf06fb6..0dc1936 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ Global rules are imported, not restated: - Read-only over Cortex's store: this server never writes memories. - The plugin install is live-mounted onto this clone; respawn the standalone HTTP server after editing Python or the running process keeps the old code. - Layers: core / server / infrastructure / handlers / hooks / shared / errors. +- **`uv sync --frozen` alone does not install `pytest` or `mutmut`** — they live in the `dev` optional-dependency group (`pyproject.toml`'s `[project.optional-dependencies] dev`), and a bare `--frozen` sync skips every extra. Always run `uv sync --frozen --extra dev` in a fresh worktree/clone before testing. + - **Symptom, not just the fix**: this fails *silently*, not loudly. `uv run pytest` still runs — it just falls through PATH to a system `pytest` (e.g. `/opt/homebrew/bin/pytest`, a different Python than `.venv`'s) instead of erroring "pytest not found". The tell is `uv run which pytest` resolving outside `.venv/bin/`, or DB-backed test modules (`test_memory_read.py`, `test_no_db_mode.py`, ...) failing collection with `ModuleNotFoundError: No module named 'psycopg'` even though `uv run python -c "import psycopg"` succeeds — the module-level import works fine standalone; only the wrong pytest interpreter can't see it. A scoped run against a couple of test files can look completely green while the full suite silently loses its collection of every DB-touching module — the whole point of `--frozen` (a reproducible, verifiable environment) is defeated by exactly the case it's meant to prevent. ## Etiquette From 1f311331c5a9af19ac1e9529ba6502a25023d39e Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 12:18:09 +0200 Subject: [PATCH 4/7] test(wiki-read): kill wiki_read.py's mutation survivors, document 7 equivalents Module 1 of issue #74's three-module mutant-killing campaign: mutmut on cortex_viz/infrastructure/wiki_read.py went from 67 survivors (recorded in the issue) to 0 unexplained -- 355/362 mutants killed, 7 documented equivalent (5 are Python codec-name case-aliasing, 2 are list_bibliography's encoding param having no observable effect since only ASCII bytes are ever inspected). Full argument for each in tests/MUTATION_NOTES.md. Notable finding during this module: a subprocess-with-LC_ALL=C approach to testing the encoding="utf-8" pins is invisible to mutmut's coverage-based test-to-mutant association (a spawned child process's execution is outside the parent's coverage trace, so such a test never gets scheduled against the mutant it targets -- it passes in isolation while the mutant still reports "survived"). Replaced with in-process locale.setlocale(LC_ALL, "C"), which Path.read_text/write_text genuinely consult (verified; monkeypatching locale.getencoding()/getpreferredencoding() does not reach them) and which mutmut's coverage tracer can see since it runs in the same process. 67 tests total in this file now (was 45), all pass locally in 0.23s. Co-Authored-By: Claude Opus 5 --- tests/MUTATION_NOTES.md | 137 ++++++++++++++++++ tests/test_wiki_read.py | 311 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 tests/MUTATION_NOTES.md diff --git a/tests/MUTATION_NOTES.md b/tests/MUTATION_NOTES.md new file mode 100644 index 0000000..ba45c20 --- /dev/null +++ b/tests/MUTATION_NOTES.md @@ -0,0 +1,137 @@ +# Mutation testing — Python scoped-run notes (issue #74) + +Per coding-standards §12, mutation testing (not line coverage) is the strength +gate on changed logic. This documents the scoped `mutmut` runs closing +issue #74's pre-existing backlog (136 survivors, discovered while wiring +`py/path-injection` fixes — see the issue for the original breakdown) in +the three path-guard modules, and triages every surviving mutant: killed, or +documented-equivalent. No survivor is left un-triaged. Format follows the +existing JS precedent (`tests/js/MUTATION_NOTES.md`). + +Run: `scripts/mutation_check.sh ` (mutmut 3.x, capped at +`--max-children 3` for these runs — the machine was shared with another +session's measurement campaign; see the PR for load/disk snapshots per run). + +## `cortex_viz/infrastructure/wiki_read.py` + +Tests: `tests/test_wiki_read.py`. **362 mutants generated, 355 killed, 7 +equivalent, 0 unexplained survivors** (was 67 survivors before this change). + +### Equivalent mutants (documented, not ignored) + +- **`x__page_item__mutmut_11`, `x_read_page__mutmut_25`, + `x_list_bibliography__mutmut_23`, `x_read_bibliography__mutmut_25`, + `x_save_page__mutmut_24` — `encoding="utf-8"` → `encoding="UTF-8"`.** + Python's codec registry normalises encoding names case-insensitively + (confirmed: `codecs.lookup("UTF-8") is codecs.lookup("utf-8")` → + `True`). The mutated call resolves to the exact same codec object as the + original; no input can distinguish them. Equivalent for every observer. + +- **`x_list_bibliography__mutmut_18`, `x_list_bibliography__mutmut_20` — + `encoding="utf-8"` dropped / set to `None` on the `.bib` read.** Unlike + the other four functions in this module, `list_bibliography`'s decoded + `text` is never returned to the caller — only `text.count("\n@")` and + `text.lstrip().startswith("@")` are ever inspected, and both operations + only look for `'@'`, `'\n'`, and whitespace, which are single-byte ASCII + bytes (0x40, 0x0A, ...) that decode identically to the same characters + under every ASCII-compatible encoding this codebase runs on (UTF-8, + Latin-1, the platform-default fallback, ...). Combined with + `errors="replace"` (untouched by these two mutants, so no exception is + possible), a wrong encoding can only corrupt non-ASCII byte sequences + elsewhere in the string — which affects neither the entry count nor the + leading-entry check nor `size` (`bib.stat().st_size`, a filesystem + property independent of how the bytes are later decoded). Verified this + is not merely "hard to trigger" but structurally unobservable: every + consumer of the decoded value is ASCII-only. + +### Notable kills — the *why*, not just the assertion + +- **`x__page_item__mutmut_6/7/8/9/12/13`, `x_read_page__mutmut_20/21/22/23/26/27`, + `x_list_bibliography__mutmut_19/21/24/25`, `x_read_bibliography__mutmut_20/21/22/23/26/27`, + `x_save_page__mutmut_20/22` + — the `errors="replace"` / `encoding="utf-8"` keyword pair on every + `read_text`/`write_text` call, dropped, set to `None`, or given an + invalid handler name (`"REPLACE"`, mutmut's `"XX...XX"` marker + wrapping).** Two distinct probes, chosen because the failure modes + differ: + - *Invalid/absent error handler*: a fixture file containing a raw + invalid UTF-8 byte (`\xff`) forces the handler to actually engage. + `errors=None` (≡ `strict`) raises `UnicodeDecodeError`; an unknown + handler name (`"REPLACE"`, `"XXreplaceXX"` — verified via + `codecs.lookup_error`, both fail lookup) raises `LookupError`. + Neither is an `OSError`, so neither is caught by the function's + `except OSError:` — the mutant crashes instead of gracefully + replacing the byte. One fixture file kills every variant of this + mutation simultaneously. + - *Wrong/absent encoding*: `locale.setlocale(locale.LC_ALL, "C")` + called **in-process** (not via a subprocess — see below) forces the + C library's preferred encoding to US-ASCII for the duration of the + test, then a page/bib file with a real non-ASCII character (`café`) + is read or written. The pinned `encoding="utf-8"` round-trips it + correctly regardless; `encoding=None` (or the kwarg dropped, which + defaults to the same `None`) decodes/encodes it wrong (`read_text` + replaces the multi-byte sequence with `U+FFFD` twice; `write_text` + raises `UnicodeEncodeError`, again uncaught by `except OSError:`). + +- **A subprocess-with-`LC_ALL=C` approach was tried first and abandoned.** + It reproduces the real divergence (verified manually: a forced + `LC_ALL=C` subprocess genuinely garbles `café` without the pin), but + mutmut selects which tests to run against a given mutant by tracing + which lines a test's *own process* executes during its coverage/stats + pass — a spawned child process's execution is invisible to that trace. + A subprocess-based test is therefore silently never scheduled against + the mutant it exists to kill: it passes in isolation, yet the mutant + it targets still reports "survived" in the real run (reproduced this + exact symptom before switching approaches). `locale.setlocale` changes + the *same* process's C-library locale state at runtime — verified that + `Path.read_text`/`Path.write_text` genuinely consult it, whereas + monkeypatching the higher-level `locale.getencoding()` / + `locale.getpreferredencoding()` Python functions does **not** reach + them (both tried and shown ineffective) — so it is both a real + behavioural probe and one mutmut's coverage tracer can see. + +- **`x__page_item__mutmut_70/71/72` (`meta.get("updated")` → + `meta.get(None)` / `"updated"` mutmut-marker-wrapped / `"UPDATED"`)** — + killed by a fixture with distinct `updated` and `tended` values, + asserting the `updated` one wins (the same precedence pattern already + covered for `created`-over-`date`). + +- **`x_read_page__mutmut_14–39` and the `x_save_page__mutmut_31/32` / + `x_read_bibliography__mutmut_28–30` "dict key" mutants** (`"error"` → + `"ERROR"`, `"path"` → `"PATH"`, etc.) — the individual-field assertions + already in the suite (`got["meta"]["tags"]`, …) don't pin the *exact* + key set; added full-dict-equality assertions on the success and + not-found/OSError response shapes, which pin every key name and value + simultaneously. + +- **`x_list_bibliography__mutmut_32` (`text.lstrip()` → `text.rstrip()` + in the leading-entry check)** — killed with a `.bib` fixture whose + first entry is preceded by *leading whitespace* (not a `\n@` pattern): + `lstrip()` strips it and the entry is counted; `rstrip()` (which only + touches the trailing end) leaves it and the entry is missed. + +- **`x_list_bibliography__mutmut_34` (`else 0` → `else 1` in the leading- + entry check)** — killed with a `.bib` fixture containing zero `@` + entries at all; the mutant unconditionally reports one anyway. + +- **`x_list_bibliography__mutmut_44` (`except OSError: continue` → + `break`)** — killed with two `.bib` files where the *first* (in sorted + order) raises `OSError` on read; `continue` reaches the second file, + `break` silently drops it from the result. + +- **`x_save_page__mutmut_3/5`, `x_read_bibliography__mutmut_3/5` + (`_safe_path(..., suffix=".md"/".bib")` → `suffix=None`, or the kwarg + dropped)** — killed by asserting the wrong-suffix refusal directly + (`save_page("notes.txt", ...)`, `read_bibliography(".../notes.txt")`), + which the suite exercised for `read_page` but not for these two + siblings. + +## `cortex_viz/server/http_standalone_static.py` + +Tests: `tests/test_static_path_traversal.py`. *(recorded after that +module's run — see PR for the exact counts.)* + +## `cortex_viz/server/http_file_diff.py` + +Tests: `tests/test_git_diff_engine.py,tests/test_file_diff.py`. *(recorded +after that module's run — see PR for the exact counts.)* diff --git a/tests/test_wiki_read.py b/tests/test_wiki_read.py index b9bf347..40b0c0c 100644 --- a/tests/test_wiki_read.py +++ b/tests/test_wiki_read.py @@ -7,6 +7,8 @@ from __future__ import annotations +import contextlib +import locale from pathlib import Path import pytest @@ -21,6 +23,42 @@ def _write_page(tmp_path, rel_path: str, content: str): return p +# ── UTF-8 pinning under a non-UTF-8 process locale ────────────────────── +# Every read_text/write_text call in this module pins encoding="utf-8" +# explicitly rather than trusting the platform default -- the CI matrix +# runs a Windows job specifically because that default is NOT UTF-8 +# everywhere. Killing an "encoding=None" mutant needs an environment where +# the default genuinely differs from UTF-8. +# +# A subprocess with LC_ALL=C reproduces that divergence but is invisible to +# mutmut's coverage-based test-to-mutant association: mutmut decides which +# tests to run for a given mutant by tracing which lines a test's OWN +# process executes during the stats pass, and a spawned child process's +# execution is outside that trace -- a subprocess-based test is silently +# never selected to run against the mutant it targets (verified empirically: +# it passes locally in isolation, yet the mutant it exists to kill still +# reports "survived" in the real run). `locale.setlocale` changes the SAME +# process's C-library locale state at runtime, which `Path.read_text` / +# `Path.write_text` genuinely consult (also verified empirically -- +# monkeypatching the higher-level `locale.getencoding` / +# `locale.getpreferredencoding` Python functions does NOT reach them; only +# the C-level `setlocale` call does) -- so this is both a real behavioural +# probe and one mutmut's coverage tracer can see. + + +@contextlib.contextmanager +def _c_locale(): + """Force the process's C-library locale to "C" (US-ASCII preferred + encoding) for the duration of the block, restoring the prior locale + afterward even on failure.""" + original = locale.setlocale(locale.LC_ALL) + try: + locale.setlocale(locale.LC_ALL, "C") + yield + finally: + locale.setlocale(locale.LC_ALL, original) + + def test_read_page_normalises_tags_to_a_list(monkeypatch, tmp_path): monkeypatch.setattr(mod, "WIKI_ROOT", tmp_path) _write_page( @@ -458,3 +496,276 @@ def test_save_page_reports_utf8_byte_length_not_character_length(monkeypatch, tm got = mod.save_page("page.md", content) assert got["bytes"] == 5 assert len(content) == 4 + + +def test_save_page_success_returns_the_exact_response_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.save_page("page.md", "hi\n") + assert got == {"ok": True, "path": "page.md", "bytes": 3} + + +def test_save_page_refuses_a_non_markdown_suffix(monkeypatch, tmp_path): + # _safe_path's suffix gate applies to writes exactly as it does to reads + # -- a non-".md" target must never be created, regardless of containment. + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.save_page("notes.txt", "x") == {"error": "invalid path"} + assert not (root / "notes.txt").exists() + + +def test_save_page_oserror_on_write_reports_the_exact_message(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + def _boom(self, content, encoding=None): + raise OSError("disk full") + + monkeypatch.setattr(Path, "write_text", _boom) + assert mod.save_page("page.md", "x") == {"error": "disk full"} + + +def test_save_page_round_trips_non_ascii_content_under_a_non_utf8_locale( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + with _c_locale(): + got = mod.save_page("page.md", "café\n") + assert got["ok"] is True, got + assert (root / "page.md").read_bytes().decode("utf-8") == "café\n" + + +# ── _parse_list: the exact strip-charsets, not any charset that happens +# to also remove brackets/quotes ────────────────────────────────────── + + +def test_parse_list_only_strips_the_bracket_characters_not_arbitrary_chars(): + # A value bracketed with 'X' rather than '[' / ']' must survive: proves + # the strip charset is exactly "[]", not a wider set that also matches + # mutmut's "XX[]XX" marker wrapping. + assert mod._parse_list("Xone, twoX") == ["Xone", "twoX"] + + +def test_parse_list_only_strips_quote_characters_not_arbitrary_chars(): + # A token bounded by 'X' (no quotes) must survive per-token stripping + # unchanged -- proves the per-token strip charset is exactly "'\"". + assert mod._parse_list("Xa, Xb") == ["Xa", "Xb"] + + +# ── _page_item: "updated" precedence, and encoding/errors robustness ──── + + +def test_page_item_prefers_updated_over_tended_fallback(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + _write_page( + root, "u.md", "---\nupdated: 2026-03-03\ntended: 2020-01-01\n---\nbody\n" + ) + item = mod._page_item(root / "u.md", root) + assert item["updated"] == "2026-03-03" + + +def test_page_item_replaces_invalid_utf8_bytes_instead_of_raising(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = root / "bad.md" + p.write_bytes(b"---\ntitle: ok\n---\nbody \xff bytes\n") + item = mod._page_item(p, root) + assert item["title"] == "ok" # must not raise, invalid byte gets replaced + + +def test_page_item_decodes_utf8_regardless_of_process_locale(tmp_path): + root = tmp_path / "wiki" + root.mkdir() + p = root / "accent.md" + p.write_bytes("---\ntitle: café\n---\nbody\n".encode()) + with _c_locale(): + item = mod._page_item(p, root) + assert item["title"] == "café" + + +# ── read_page: exact response shapes, and the OSError arm ──────────────── + + +def test_read_page_not_found_returns_the_exact_error_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_page("missing.md") == {"error": "not found"} + + +def test_read_page_success_returns_the_exact_response_shape(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "page.md", "---\ntitle: X\n---\nhello\n") + assert mod.read_page("page.md") == { + "path": "page.md", + "meta": {"title": "X"}, + "body": "hello", + } + + +def test_read_page_oserror_on_read_reports_the_exact_message(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "page.md", "body\n") + + def _boom(self, encoding=None, errors=None): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + assert mod.read_page("page.md") == {"error": "permission denied"} + + +def test_read_page_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + (root / "bad.md").write_bytes(b"---\ntitle: ok\n---\nbody \xff bytes\n") + got = mod.read_page("bad.md") + assert "error" not in got + assert got["meta"]["title"] == "ok" + + +def test_read_page_decodes_utf8_regardless_of_process_locale(monkeypatch, tmp_path): + root = tmp_path / "wiki" + root.mkdir() + monkeypatch.setattr(mod, "WIKI_ROOT", root) + _write_page(root, "accent.md", "---\ntitle: X\n---\ncafé\n") + with _c_locale(): + got = mod.read_page("accent.md") + assert got["body"] == "café" + + +# ── list_bibliography: leading-entry boundary, the else-arm, OSError +# continue-vs-break, and invalid-byte robustness ────────────────────── + + +def test_list_bibliography_counts_a_leading_entry_preceded_only_by_whitespace( + monkeypatch, tmp_path +): + # Leading SPACES (not a preceding "\n@") before the first "@" -- distinguishes + # lstrip() (strips them, sees "@...", counts it) from rstrip() (leaves them, + # the text does not start with "@", would NOT count it). + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "lead.bib").write_text(" @book{a,\n title={A}\n}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 + + +def test_list_bibliography_reports_zero_entries_for_a_file_with_no_at_sign( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "empty.bib").write_text("no entries in this file\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 0 + + +def test_list_bibliography_skips_an_unreadable_file_and_still_lists_the_rest( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "a_broken.bib").write_text("@book{a}\n", encoding="utf-8") + (bib_dir / "b_ok.bib").write_text("@book{b}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + real_read_text = Path.read_text + + def _flaky(self, *a, **k): + if self.name == "a_broken.bib": + raise OSError("unreadable") + return real_read_text(self, *a, **k) + + monkeypatch.setattr(Path, "read_text", _flaky) + got = mod.list_bibliography() + names = {f["path"] for f in got["files"]} + # "continue" (not "break") must let the loop reach b_ok.bib despite + # a_broken.bib raising first (sorted order puts a_broken.bib first). + assert names == {"_bibliography/b_ok.bib"} + + +def test_list_bibliography_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "bad.bib").write_bytes(b"@book{a,\n title={A \xff B}\n}\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.list_bibliography() + assert got["files"][0]["entries"] == 1 # did not raise; counted normally + + +# ── read_bibliography: suffix gate, exact OSError shape, invalid bytes, +# and encoding robustness (content IS surfaced here, unlike list) ───── + + +def test_read_bibliography_refuses_a_non_bib_suffix(monkeypatch, tmp_path): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "notes.txt").write_text("not a bib file", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + assert mod.read_bibliography("_bibliography/notes.txt") == {"error": "invalid path"} + + +def test_read_bibliography_oserror_on_read_reports_the_exact_message( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "refs.bib").write_text("@book{a}\n", encoding="utf-8") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + + def _boom(self, encoding=None, errors=None): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _boom) + assert mod.read_bibliography("_bibliography/refs.bib") == { + "error": "permission denied" + } + + +def test_read_bibliography_replaces_invalid_utf8_bytes_instead_of_raising( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "bad.bib").write_bytes(b"@book{a,\n title={A \xff B}\n}\n") + monkeypatch.setattr(mod, "WIKI_ROOT", root) + got = mod.read_bibliography("_bibliography/bad.bib") + assert "error" not in got + assert "�" in got["content"] + + +def test_read_bibliography_decodes_utf8_regardless_of_process_locale( + monkeypatch, tmp_path +): + root = tmp_path / "wiki" + bib_dir = root / "_bibliography" + bib_dir.mkdir(parents=True) + (bib_dir / "accent.bib").write_bytes("@book{a, title={café}}\n".encode()) + monkeypatch.setattr(mod, "WIKI_ROOT", root) + with _c_locale(): + got = mod.read_bibliography("_bibliography/accent.bib") + assert got["content"] == "@book{a, title={café}}\n" From b789c88e4fb7104f70a726226e306984c19408c9 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 12:28:29 +0200 Subject: [PATCH 5/7] test(static-guard): kill http_standalone_static.py's mutation survivors Module 2 of issue #74's three-module mutant-killing campaign: mutmut on cortex_viz/server/http_standalone_static.py went from 53 survivors (recorded in the issue) to 0 unexplained -- 147/152 mutants killed, 5 documented equivalent. Full argument for each in tests/MUTATION_NOTES.md. Two equivalence classes, both provable from the guard's own final regex/ predicate rather than asserted by inspection: serve_static's early clauses (empty/dot-prefix/null-byte) are each independently implied by its closing `re.match(r"^[\w][\w.\-]*$", ...)`, verified against the relevant string classes; serve_shared_asset's explicit ".." segment check is redundant with its own dot-prefix check since ".." always starts with ".". Also closes a real gap: serve_file_diff (the thin delegate to http_file_diff.serve_file_diff) had zero associated tests before this change ("no tests" in mutmut's own report, not merely "survived") -- added wiring tests proving both the handler and the store cross the delegation boundary intact. 64 tests total in this file now (was 57), all pass locally in 0.4s. Co-Authored-By: Claude Opus 5 --- tests/MUTATION_NOTES.md | 74 ++++++++++++++++- tests/test_static_path_traversal.py | 121 ++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) diff --git a/tests/MUTATION_NOTES.md b/tests/MUTATION_NOTES.md index ba45c20..4acc7ed 100644 --- a/tests/MUTATION_NOTES.md +++ b/tests/MUTATION_NOTES.md @@ -128,8 +128,78 @@ equivalent, 0 unexplained survivors** (was 67 survivors before this change). ## `cortex_viz/server/http_standalone_static.py` -Tests: `tests/test_static_path_traversal.py`. *(recorded after that -module's run — see PR for the exact counts.)* +Tests: `tests/test_static_path_traversal.py`. **152 mutants generated, 147 +killed, 5 equivalent, 0 unexplained survivors** (was 53 survivors before +this change). + +### Equivalent mutants (documented, not ignored) + +- **`x_serve_static__mutmut_4/5/8/9` — the `or`-chain in `serve_static`'s + filename guard** (`startswith(".") or "\x00" in name` fused into `and`, + or either literal turned into a mutmut marker string that can never + match a real filename). `serve_static`'s guard ends with + `re.match(r"^[\w][\w.\-]*$", safe_name)`, and this regex **already + independently implies** every property the earlier clauses check: + empty (`^[\w]` requires one char), dot-prefixed (`.` is never `\w`), + and null-byte-containing (`\x00` is in none of `\w`, `.`, `-`) names + all fail the regex on their own. Verified directly — + `re.compile(r"^[\w][\w.\-]*$").match(x)` returns `False` for `''`, + `'.'`, `'.hidden'`, and every string containing `'\x00'`, tested + exhaustively for the relevant classes. Weakening or disabling the + earlier clauses changes nothing observable: the regex is the load- + bearing check; the clauses ahead of it are early-exit optimizations + over an already-total condition, not independently-observable guards. + +- **`x_serve_shared_asset__mutmut_10` — `part in ("", "..")` with `".."` + replaced by a mutmut marker string that can never match a real path + segment.** The same segment-rejection `any(...)` also checks + `part.startswith(".")`, and `".."` trivially satisfies that (every + string starting with two dots starts with one). Any segment equal to + literal `".."` is therefore always also caught by the dot-prefix + clause; the explicit `".."` membership check is redundant for that + specific value. (Distinct from the OR→AND fusion mutant on this same + line, which killed cleanly — that one disables both clauses + simultaneously for a plain empty segment, which does NOT start with + `"."` and has no other catching clause.) + +### Notable kills — the *why*, not just the assertion + +- **`x_serve_static__mutmut_36`, `x_serve_shared_asset__mutmut_19` (403 → + 404 on the containment/segment-rejection refusal paths)** — the existing + traversal-payload tests asserted `status in (403, 404)` (both are "safe" + outcomes), which cannot distinguish a deliberate refusal from a plain + not-found. Added exact-403 assertions for a symlink escape (`serve_static`) + and an empty `rel_path` (`serve_shared_asset`) specifically. + +- **`x_serve_shared_asset__mutmut_7/9/12/13/14` + (the `any(part in ("", "..") or part.startswith(".") for part in + rel_path.split("/"))` segment-rejection predicate — OR fused to AND, + each literal replaced by a dead marker string, and the split delimiter + itself replaced by `None` or a dead marker)** — killed by three + payloads chosen so each nets to a *legitimately contained* file if the + pre-check is bypassed, proving the pre-check is genuine defense-in-depth + and not redundant with `resolve_under`'s containment check alone: a + dot-prefixed file that actually exists in the sandbox + (`.hidden-but-real.css`), a doubled separator that POSIX would collapse + to a real file (`tokens//colors.css`), and a literal `..` segment that + nets back inside the sandbox (`tokens/../ds.css`). All three must be + refused with exactly 403 even though `resolve_under` alone would have + accepted the resolved path. + +- **`x_serve_file_diff__mutmut_1/2/3/4` (`_serve(None, store)`, + `_serve(handler, None)`, `_serve(store)` — wrong argument, dropped + argument, wrong arity) — 0 tests were associated with this function at + all before this change.** `serve_file_diff` here is a documented thin + delegate to `http_file_diff.serve_file_diff`; its entire observable + contract is "forwards both arguments intact." Killed with two tests + that route a bare-basename query through the delegate: one with + `store=None` (yields `"unresolved basename: activity store + unavailable"`), one with a real store object (`store=object()`, yields + the *different* `"unresolved basename: activity store lookup failed"` + reason via `_resolve_by_basename`'s real lookup-exception arm). Reaching + either reason at all rules out `handler=None` (which crashes resolving + `handler.path` before any JSON is written); the reason *differing* + between the two calls rules out `store` being dropped or defaulted. ## `cortex_viz/server/http_file_diff.py` diff --git a/tests/test_static_path_traversal.py b/tests/test_static_path_traversal.py index c958684..c2db8b6 100644 --- a/tests/test_static_path_traversal.py +++ b/tests/test_static_path_traversal.py @@ -322,3 +322,124 @@ def test_static_unknown_filename_is_404_not_403(flat_dir: Path) -> None: serve_static(h, flat_dir, "does-not-exist.js", "application/javascript") assert h.status == 404 assert h.body == b"" + + +def test_static_symlink_escape_is_exactly_403_not_404(flat_dir: Path) -> None: + # Distinguishes the containment refusal (403, deliberately rejected) + # from a plain not-found (404) -- both are "safe" outcomes but only one + # is the correct status for a name that WAS found and refused. + (flat_dir / "evil.js").symlink_to(flat_dir.parent / "secret.txt") + h = FakeHandler() + serve_static(h, flat_dir, "evil.js", "application/javascript") + assert h.status == 403 + assert h.body == b"" + + +# ── serve_shared_asset segment-rejection pre-check — the ``or``-chain over +# (empty / ".." / dot-prefixed) is checked PER SEGMENT before containment +# ever runs. resolve_under alone catches actual escapes, so these +# assertions must construct a payload that IS legitimately contained yet +# must still be refused pre-check, as defense-in-depth against ambiguous +# input -- otherwise the pre-check reads as untestable dead code when it +# is not. ──────────────────────────────────────────────────────────── + + +def test_shared_asset_refuses_a_dot_prefixed_segment_even_if_the_file_exists( + sandbox: Path, +) -> None: + (sandbox / ".hidden-but-real.css").write_text("/* h */", encoding="utf-8") + h = FakeHandler() + serve_shared_asset(h, sandbox, ".hidden-but-real.css") + assert h.status == 403, "a dot-prefixed segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_refuses_an_empty_segment_even_if_it_resolves_to_a_real_file( + sandbox: Path, +) -> None: + # A doubled separator produces an empty path segment; POSIX path + # resolution would collapse it to the real, legitimately-contained + # file -- the segment-level pre-check must refuse it before that + # resolution ever happens. + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens//colors.css") + assert h.status == 403, "an empty path segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_refuses_a_dotdot_segment_even_when_it_nets_to_a_contained_file( + sandbox: Path, +) -> None: + # "tokens/../ds.css" resolves (via resolve_under) to the legitimately + # contained ds.css -- the pre-check must still refuse the literal ".." + # segment outright, never delegating the decision to containment alone. + h = FakeHandler() + serve_shared_asset(h, sandbox, "tokens/../ds.css") + assert h.status == 403, "a literal '..' segment must be refused pre-check" + assert h.body == b"" + + +def test_shared_asset_segment_refusal_is_exactly_403_not_404(sandbox: Path) -> None: + h = FakeHandler() + serve_shared_asset(h, sandbox, "") + assert h.status == 403 + + +# ── serve_file_diff — the delegate must forward BOTH the handler and the +# store intact, not drop either or swap positions. A "no name given" +# smoke call can't distinguish a dropped ``store`` (both produce the +# same reason); using a bare-basename query routes through the +# store-dependent resolver arm, whose reason text differs by whether a +# real store object crossed the delegation boundary. ───────────────── + + +class _FakeDiffHandler: + def __init__(self, path: str) -> None: + self.path = path + self.status: int | None = None + self.headers: dict[str, str] = {} + self.body = b"" + self.wfile = self + + def send_response(self, code: int) -> None: + self.status = code + + def send_header(self, k: str, v: str) -> None: + self.headers[k] = v + + def end_headers(self) -> None: + pass + + def write(self, data: bytes) -> None: + self.body += data + + +def _diff_reason(h: _FakeDiffHandler) -> str: + import json + + return json.loads(h.body)["reason"] + + +def test_serve_file_diff_forwards_the_real_handler_not_none() -> None: + from cortex_viz.server.http_standalone_static import serve_file_diff + + # handler=None would crash resolving handler.path before any JSON is + # ever written -- reaching a clean JSON response at all proves the + # real handler crossed the delegation boundary. + h = _FakeDiffHandler("/api/file-diff?name=foo.py") + serve_file_diff(h, store=None) + assert h.status == 200 + assert _diff_reason(h) == "unresolved basename: activity store unavailable" + + +def test_serve_file_diff_forwards_the_real_store_not_none() -> None: + from cortex_viz.server.http_standalone_static import serve_file_diff + + # A non-None store routes _resolve_by_basename into the lookup-attempt + # arm (a different reason string than the store-absent arm above) -- + # this only happens if `store` genuinely crossed the boundary rather + # than being dropped or defaulted. + h = _FakeDiffHandler("/api/file-diff?name=foo.py") + serve_file_diff(h, store=object()) + assert h.status == 200 + assert _diff_reason(h) == "unresolved basename: activity store lookup failed" From 19519efe06a618a62170bce6c91470d41a6710d0 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 12:34:31 +0200 Subject: [PATCH 6/7] test(file-diff): kill http_file_diff.py's mutation survivors, 0 equivalents Module 3 (final) of issue #74's three-module mutant-killing campaign: mutmut on cortex_viz/server/http_file_diff.py went from 16 survivors (recorded in the issue) to 0 -- 120/120 mutants killed, no equivalents needed. Details in tests/MUTATION_NOTES.md. Every survivor here was a genuine test gap, not an unobservable difference: four call sites forward `store` through the resolution chain (_resolve_by_basename -> find_abs_path_by_label, _resolve_by_relative_fragment -> find_abs_path_by_suffix, _resolve_name -> _resolve_by_relative_fragment, serve_file_diff -> _resolve_name) and the existing tests monkeypatched the lookup functions with lambdas that ignored the `store` argument entirely, so a dropped/None-swapped store was invisible to them -- fixed by recording every store value actually received. One reason string's substring assertion was loosened enough to pass under mutmut's "XX...XX" wrapping; tightened to exact equality. One response dict's full shape (5 keys) was only 3/5 asserted; switched to full-dict equality. Full suite: 1310 passed, 10 skipped (was 1279/10 before this campaign), ruff check and format both clean across the whole repo. Co-Authored-By: Claude Opus 5 --- tests/MUTATION_NOTES.md | 40 +++++++++++++++++++- tests/test_file_diff.py | 70 ++++++++++++++++++++++++++++++----- tests/test_git_diff_engine.py | 22 +++++++---- 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/tests/MUTATION_NOTES.md b/tests/MUTATION_NOTES.md index 4acc7ed..dcf2643 100644 --- a/tests/MUTATION_NOTES.md +++ b/tests/MUTATION_NOTES.md @@ -203,5 +203,41 @@ this change). ## `cortex_viz/server/http_file_diff.py` -Tests: `tests/test_git_diff_engine.py,tests/test_file_diff.py`. *(recorded -after that module's run — see PR for the exact counts.)* +Tests: `tests/test_git_diff_engine.py,tests/test_file_diff.py`. **120 +mutants generated, 120 killed, 0 equivalent, 0 survivors** (was 16 +survivors before this change). + +### Notable kills — the *why*, not just the assertion + +- **`x__resolve_by_basename__mutmut_5`, `x__resolve_by_relative_fragment__mutmut_23`, + `x__resolve_name__mutmut_7`, `x_serve_file_diff__mutmut_33` + (`store` swapped for `None` at four distinct call sites along the + resolution chain: `_resolve_by_basename` -> `find_abs_path_by_label`, + `_resolve_by_relative_fragment` -> `find_abs_path_by_suffix`, + `_resolve_name` -> `_resolve_by_relative_fragment`, and + `serve_file_diff` -> `_resolve_name`)** — the existing store-forwarding + tests monkeypatched the lookup functions with lambdas that *ignored* + their `store` parameter, so a dropped/`None`-swapped store was + invisible to them. Each of the four call sites needed its own + independent proof: a lambda/closure that records every `store` value it + actually received and asserts the list equals `[sentinel_store]` — a + passed-through `None` swap shows up as `[None]` or a length mismatch + instead. + +- **`x__resolve_by_basename__mutmut_11` (`"unresolved basename: not found + in activity index"` wrapped in mutmut's `"XX...XX"` marker)** — the + existing test asserted `"unresolved basename" in reason` (substring), + which the wrapped string still contains. Tightened to exact string + equality, which the JS notes precedent already established as the + right default for reason/error strings in this codebase. + +- **`x_serve_file_diff__mutmut_49/50/51/52/53` (the "unresolved name" + response dict's `"lines"`/`"truncated"` keys and the `False` -> `True` + value mutant)** — the existing test asserted three of the five keys + individually (`available`, `diff_type`, `reason`), missing `lines` and + `truncated` entirely. Switched to full-dict equality against the + literal five-key response, matching the pattern already used for the + "no file given" branch's equivalent full-dict test. + +No equivalent mutants in this module — every survivor from the original +run was a genuine test gap, not an unobservable difference. diff --git a/tests/test_file_diff.py b/tests/test_file_diff.py index e5d67cd..2ba9555 100644 --- a/tests/test_file_diff.py +++ b/tests/test_file_diff.py @@ -141,14 +141,42 @@ def test_resolve_by_relative_fragment_falls_back_to_activity_suffix_search( import cortex_viz.infrastructure.activity_store as activity_store from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment - monkeypatch.setattr( - activity_store, - "find_abs_path_by_suffix", - lambda store, name: "/repo/src/found.py" if name == "src/found.py" else None, - ) - abs_path, reason = _resolve_by_relative_fragment(object(), "src/found.py") + sentinel_store = object() + seen_stores = [] + + def _fake(store, name): + seen_stores.append(store) + return "/repo/src/found.py" if name == "src/found.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) + abs_path, reason = _resolve_by_relative_fragment(sentinel_store, "src/found.py") + assert abs_path == "/repo/src/found.py" + assert reason is None + assert seen_stores == [sentinel_store] + + +def test_resolve_name_forwards_the_exact_store_to_the_relative_fragment_resolver( + monkeypatch, +): + # _resolve_name's relative-path branch delegates to + # _resolve_by_relative_fragment(store, ...) -- distinct call site from + # the basename branch (covered above), and must forward the same store + # object rather than defaulting it to None along the way. + import cortex_viz.infrastructure.activity_store as activity_store + from cortex_viz.server.http_file_diff import _resolve_name + + sentinel_store = object() + seen_stores = [] + + def _fake(store, name): + seen_stores.append(store) + return "/repo/src/found.py" if name == "src/found.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) + abs_path, reason = _resolve_name(sentinel_store, "src/found.py") assert abs_path == "/repo/src/found.py" assert reason is None + assert seen_stores == [sentinel_store] def test_resolve_by_relative_fragment_suffix_search_not_found_reports_reason( @@ -266,9 +294,33 @@ def test_serve_file_diff_unresolvable_name_reports_reason_and_unavailable(): mod_local.serve_file_diff(h, store=None) assert h.status == 200 payload = _decode_json(h) - assert payload["available"] is False - assert payload["diff_type"] == "none" - assert payload["reason"] == "unresolved basename: activity store unavailable" + assert payload == { + "available": False, + "diff_type": "none", + "lines": [], + "truncated": False, + "reason": "unresolved basename: activity store unavailable", + } + + +def test_serve_file_diff_forwards_the_exact_store_to_resolve_name(monkeypatch): + # serve_file_diff's own call to _resolve_name(store, name) is a distinct + # call site from every store-forwarding test above (those exercise the + # resolver functions directly) -- must not default/drop `store` at the + # top of the HTTP entry point itself. + import cortex_viz.server.http_file_diff as mod_local + + sentinel_store = object() + seen_stores = [] + + def _fake_resolve_name(store, name): + seen_stores.append(store) + return None, "stub reason" + + monkeypatch.setattr(mod_local, "_resolve_name", _fake_resolve_name) + h = _FakeHandler("/api/file-diff?name=foo.py") + mod_local.serve_file_diff(h, store=sentinel_store) + assert seen_stores == [sentinel_store] def test_serve_file_diff_resolves_absolute_path_and_delegates_to_the_engine( diff --git a/tests/test_git_diff_engine.py b/tests/test_git_diff_engine.py index 3c9bf19..bb0a77a 100644 --- a/tests/test_git_diff_engine.py +++ b/tests/test_git_diff_engine.py @@ -358,14 +358,21 @@ def test_resolve_name_basename_resolves_via_activity_store(monkeypatch): import cortex_viz.infrastructure.activity_store as activity_store from cortex_viz.server.http_file_diff import _resolve_name - monkeypatch.setattr( - activity_store, - "find_abs_path_by_label", - lambda store, label: "/Users/dev/repo/foo.py" if label == "foo.py" else None, - ) - abs_path, reason = _resolve_name(object(), "foo.py") + sentinel_store = object() + seen_stores = [] + + def _fake(store, label): + seen_stores.append(store) + return "/Users/dev/repo/foo.py" if label == "foo.py" else None + + monkeypatch.setattr(activity_store, "find_abs_path_by_label", _fake) + abs_path, reason = _resolve_name(sentinel_store, "foo.py") assert abs_path == "/Users/dev/repo/foo.py" assert reason is None + # The exact store object must cross every layer (_resolve_name -> + # _resolve_by_basename -> find_abs_path_by_label) unchanged, not be + # swapped for None along the way. + assert seen_stores == [sentinel_store] def test_resolve_name_basename_unresolved_reports_reason(monkeypatch): @@ -379,8 +386,7 @@ def test_resolve_name_basename_unresolved_reports_reason(monkeypatch): ) abs_path, reason = _resolve_name(object(), "missing.py") assert abs_path is None - assert reason is not None - assert "unresolved basename" in reason + assert reason == "unresolved basename: not found in activity index" def test_resolve_name_never_falls_back_to_server_cwd(monkeypatch): From 76c6dac5e03a7a7bbf1d54ad94950ed6b8000185 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 13:05:15 +0200 Subject: [PATCH 7/7] fix(test): import http_file_diff through a single module alias CodeQL flagged five threads (lines 275, 291, 311, 331, 347): this file imported cortex_viz.server.http_file_diff both as named symbols (_resolve_by_relative_fragment at module scope; _resolve_by_basename, _resolve_name imported locally in several tests) and as a module alias (`import ... as mod_local`, needed to monkeypatch module attributes in three tests). Two references to the same module let a reader lose track of which form is live where, and that ambiguity is exactly what makes a test file fragile to edit later. Standardized on a single top-level `import cortex_viz.server.http_file_diff as hfd`, since the module-attribute form is the one the monkeypatching tests require; every call site that used to import a symbol by name now calls it through `hfd.` instead. No behavior change -- 17/17 tests in this file still pass, full suite still 1310 passed/10 skipped. Verified the other three test files this PR touches (test_wiki_read.py, test_static_path_traversal.py, test_git_diff_engine.py) do not carry the same dual-import pattern for any cortex_viz module. Co-Authored-By: Claude Opus 5 --- tests/test_file_diff.py | 47 +++++++++++++---------------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/tests/test_file_diff.py b/tests/test_file_diff.py index 2ba9555..1b0ad7c 100644 --- a/tests/test_file_diff.py +++ b/tests/test_file_diff.py @@ -12,13 +12,13 @@ from pathlib import Path from typing import ClassVar +import cortex_viz.server.http_file_diff as hfd from cortex_viz.server.git_diff_engine import ( _MAX_LINES, _full_content_as_adds, _parse_unified, _resolve_diff, ) -from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment _SAMPLE = """diff --git a/f.txt b/f.txt index 1234567..89abcde 100644 @@ -102,7 +102,7 @@ def test_resolve_diff_type_selection(tmp_path: Path): def test_resolve_by_relative_fragment_rejects_path_traversal(): # A '..' segment must never be joined onto a repo root — otherwise a # crafted ``name`` query param could escape the repo (CWE-22). - abs_path, reason = _resolve_by_relative_fragment(None, "../../etc/passwd") + abs_path, reason = hfd._resolve_by_relative_fragment(None, "../../etc/passwd") assert abs_path is None assert reason == "unresolved relative name: path traversal rejected" @@ -116,13 +116,12 @@ def test_resolve_by_relative_fragment_rejects_path_traversal(): def test_resolve_by_basename_store_lookup_exception_reports_reason(monkeypatch): import cortex_viz.infrastructure.activity_store as activity_store - from cortex_viz.server.http_file_diff import _resolve_by_basename def _boom(store, label): raise RuntimeError("db down") monkeypatch.setattr(activity_store, "find_abs_path_by_label", _boom) - abs_path, reason = _resolve_by_basename(object(), "foo.py") + abs_path, reason = hfd._resolve_by_basename(object(), "foo.py") assert abs_path is None assert reason == "unresolved basename: activity store lookup failed" @@ -130,7 +129,7 @@ def _boom(store, label): def test_resolve_by_relative_fragment_store_none_after_no_repo_match_reports_reason(): # No registry repo matches and store is None -- distinct message from # the "no such basename" and "activity store lookup failed" arms. - abs_path, reason = _resolve_by_relative_fragment(None, "no/such/repo/file.py") + abs_path, reason = hfd._resolve_by_relative_fragment(None, "no/such/repo/file.py") assert abs_path is None assert reason == "unresolved relative name: not found in known repos" @@ -139,7 +138,6 @@ def test_resolve_by_relative_fragment_falls_back_to_activity_suffix_search( monkeypatch, ): import cortex_viz.infrastructure.activity_store as activity_store - from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment sentinel_store = object() seen_stores = [] @@ -149,7 +147,7 @@ def _fake(store, name): return "/repo/src/found.py" if name == "src/found.py" else None monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) - abs_path, reason = _resolve_by_relative_fragment(sentinel_store, "src/found.py") + abs_path, reason = hfd._resolve_by_relative_fragment(sentinel_store, "src/found.py") assert abs_path == "/repo/src/found.py" assert reason is None assert seen_stores == [sentinel_store] @@ -163,7 +161,6 @@ def test_resolve_name_forwards_the_exact_store_to_the_relative_fragment_resolver # the basename branch (covered above), and must forward the same store # object rather than defaulting it to None along the way. import cortex_viz.infrastructure.activity_store as activity_store - from cortex_viz.server.http_file_diff import _resolve_name sentinel_store = object() seen_stores = [] @@ -173,7 +170,7 @@ def _fake(store, name): return "/repo/src/found.py" if name == "src/found.py" else None monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _fake) - abs_path, reason = _resolve_name(sentinel_store, "src/found.py") + abs_path, reason = hfd._resolve_name(sentinel_store, "src/found.py") assert abs_path == "/repo/src/found.py" assert reason is None assert seen_stores == [sentinel_store] @@ -183,12 +180,11 @@ def test_resolve_by_relative_fragment_suffix_search_not_found_reports_reason( monkeypatch, ): import cortex_viz.infrastructure.activity_store as activity_store - from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment monkeypatch.setattr( activity_store, "find_abs_path_by_suffix", lambda store, name: None ) - abs_path, reason = _resolve_by_relative_fragment(object(), "src/missing.py") + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/missing.py") assert abs_path is None assert reason == ( "unresolved relative name: not found in activity index or known repos" @@ -199,13 +195,12 @@ def test_resolve_by_relative_fragment_suffix_search_exception_reports_reason( monkeypatch, ): import cortex_viz.infrastructure.activity_store as activity_store - from cortex_viz.server.http_file_diff import _resolve_by_relative_fragment def _boom(store, name): raise RuntimeError("db down") monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) - abs_path, reason = _resolve_by_relative_fragment(object(), "src/anything.py") + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/anything.py") assert abs_path is None assert reason == "unresolved relative name: activity store lookup failed" @@ -236,7 +231,7 @@ def _boom(store, name): # pragma: no cover - must never run raise AssertionError("suffix search must not run when a repo root matches") monkeypatch.setattr(activity_store, "find_abs_path_by_suffix", _boom) - abs_path, reason = _resolve_by_relative_fragment(object(), "src/file.py") + abs_path, reason = hfd._resolve_by_relative_fragment(object(), "src/file.py") assert abs_path == str(repo_dir / "src" / "file.py") assert reason is None @@ -272,10 +267,8 @@ def _decode_json(handler: _FakeHandler) -> dict: def test_serve_file_diff_missing_name_param_reports_no_file_given(): - import cortex_viz.server.http_file_diff as mod_local - h = _FakeHandler("/api/file-diff") - mod_local.serve_file_diff(h) + hfd.serve_file_diff(h) assert h.status == 200 payload = _decode_json(h) assert payload == { @@ -288,10 +281,8 @@ def test_serve_file_diff_missing_name_param_reports_no_file_given(): def test_serve_file_diff_unresolvable_name_reports_reason_and_unavailable(): - import cortex_viz.server.http_file_diff as mod_local - h = _FakeHandler("/api/file-diff?name=nowhere.py") - mod_local.serve_file_diff(h, store=None) + hfd.serve_file_diff(h, store=None) assert h.status == 200 payload = _decode_json(h) assert payload == { @@ -308,8 +299,6 @@ def test_serve_file_diff_forwards_the_exact_store_to_resolve_name(monkeypatch): # call site from every store-forwarding test above (those exercise the # resolver functions directly) -- must not default/drop `store` at the # top of the HTTP entry point itself. - import cortex_viz.server.http_file_diff as mod_local - sentinel_store = object() seen_stores = [] @@ -317,26 +306,22 @@ def _fake_resolve_name(store, name): seen_stores.append(store) return None, "stub reason" - monkeypatch.setattr(mod_local, "_resolve_name", _fake_resolve_name) + monkeypatch.setattr(hfd, "_resolve_name", _fake_resolve_name) h = _FakeHandler("/api/file-diff?name=foo.py") - mod_local.serve_file_diff(h, store=sentinel_store) + hfd.serve_file_diff(h, store=sentinel_store) assert seen_stores == [sentinel_store] def test_serve_file_diff_resolves_absolute_path_and_delegates_to_the_engine( tmp_path: Path, ): - import subprocess - - import cortex_viz.server.http_file_diff as mod_local - root = tmp_path / "repo" root.mkdir() subprocess.run(["git", "-C", str(root), "init", "-q"], check=True) (root / "new.txt").write_text("alpha\n") h = _FakeHandler(f"/api/file-diff?name={root / 'new.txt'}") - mod_local.serve_file_diff(h) + hfd.serve_file_diff(h) assert h.status == 200 payload = _decode_json(h) assert payload["available"] is True @@ -344,8 +329,6 @@ def test_serve_file_diff_resolves_absolute_path_and_delegates_to_the_engine( def test_serve_file_diff_unexpected_exception_yields_json_error(): - import cortex_viz.server.http_file_diff as mod_local - class _BrokenHandler(_FakeHandler): @property def path(self): # noqa: D401 - raising on access forces the except branch @@ -356,7 +339,7 @@ def path(self, value): pass h = _BrokenHandler("/api/file-diff?name=x") - mod_local.serve_file_diff(h) + hfd.serve_file_diff(h) assert h.status == 500 payload = _decode_json(h) assert payload == {"error": "RuntimeError"}