Ctrl K
ComunidadGuías…

tests/test_hook_content_freshness.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""A checkout's newer timestamps are not evidence of changed extraction content."""
import json
import os
from pathlib import Path

import pytest

import graphify.paths as paths
from graphify.detect import save_manifest
from tests.test_hook_strict import _fixture, _invoke, _is_deny, _read


def _project(tmp_path, *, kind="both"):
    f = _fixture(tmp_path)
    manifest = tmp_path / "graphify-out" / "manifest.json"
    # Use the extraction writer, not a guessed cache/hash schema.
    save_manifest({"code": [str(f)]}, str(manifest), kind=kind, root=tmp_path)
    graph = manifest.with_name("graph.json")
    os.utime(graph, (100, 100))
    os.utime(f, (200, 200))
    return f, manifest


def _assert_stale(f, tmp_path, monkeypatch):
    out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
    assert not _is_deny(out)
    assert "stale" in out.lower()


@pytest.mark.parametrize("kind", ["ast", "semantic", "both"])
@pytest.mark.parametrize("relative", [False, True])
def test_newer_unchanged_file_uses_real_extraction_hash(tmp_path, monkeypatch, kind, relative):
    f, _ = _project(tmp_path, kind=kind)
    target = f.relative_to(tmp_path) if relative else f
    assert _is_deny(_invoke("read", _read(target), tmp_path, monkeypatch, strict=True))


def test_decomposed_filename_matches_normalized_manifest_key(tmp_path, monkeypatch):
    f, manifest = _project(tmp_path)
    decomposed = f.with_name("cafe\u0301.py")
    f.rename(decomposed)
    save_manifest({"code": [str(decomposed)]}, str(manifest), root=tmp_path)
    assert "src/caf\u00e9.py" in json.loads(manifest.read_text(encoding="utf-8"))
    os.utime(decomposed, (200, 200))

    out = _invoke("read", _read(decomposed), tmp_path, monkeypatch, strict=True)

    assert "stale" not in out.lower()
    assert "knowledge graph at" in out


def test_same_length_content_change_stays_stale(tmp_path, monkeypatch):
    f, _ = _project(tmp_path)
    f.write_text("def x():\n    return 2\n", encoding="utf-8")
    _assert_stale(f, tmp_path, monkeypatch)


def test_needs_update_wins_over_matching_hash(tmp_path, monkeypatch):
    f, manifest = _project(tmp_path)
    manifest.with_name("needs_update").write_text("1", encoding="utf-8")
    _assert_stale(f, tmp_path, monkeypatch)


@pytest.mark.parametrize("bad", [
    "missing", "corrupt", "oversized", "unknown", "non-dict", "non-dict-row",
    "missing-hash", "invalid-hash", "non-string-hash", "empty-hashes", "invalid-tier",
])
def test_uncertain_manifest_stays_stale(tmp_path, monkeypatch, bad):
    f, manifest = _project(tmp_path)
    data = json.loads(manifest.read_text(encoding="utf-8"))
    if bad == "missing":
        manifest.unlink()
    elif bad == "corrupt":
        manifest.write_text("{", encoding="utf-8")
    elif bad == "oversized":
        # Valid JSON with a matching row must still be rejected over the cap.
        manifest.write_text(json.dumps(data) + " " * 2_000_001, encoding="utf-8")
    else:
        if bad == "unknown":
            data = {"other.py": data["src/mod.py"]}
        elif bad == "non-dict":
            data = []
        elif bad == "non-dict-row":
            data["src/mod.py"] = 100
        elif bad == "missing-hash":
            data["src/mod.py"] = {"mtime": 100}
        elif bad == "invalid-hash":
            data["src/mod.py"]["ast_hash"] = "not-an-extraction-hash"
        elif bad == "non-string-hash":
            data["src/mod.py"]["ast_hash"] = ["hash"]
        elif bad == "empty-hashes":
            data["src/mod.py"] = {"ast_hash": "", "semantic_hash": ""}
        elif bad == "invalid-tier":
            data["src/mod.py"]["semantic_hash"] = None
        manifest.write_text(json.dumps(data), encoding="utf-8")
    _assert_stale(f, tmp_path, monkeypatch)


@pytest.mark.parametrize("stale_tier", ["ast_hash", "semantic_hash"])
def test_one_matching_tier_cannot_hide_other_stale_tier(tmp_path, monkeypatch, stale_tier):
    f, manifest = _project(tmp_path)
    data = json.loads(manifest.read_text(encoding="utf-8"))
    data["src/mod.py"][stale_tier] = "0" * 32
    manifest.write_text(json.dumps(data), encoding="utf-8")
    _assert_stale(f, tmp_path, monkeypatch)


@pytest.mark.parametrize("absolute_key", [False, True])
def test_legacy_hash_schema(tmp_path, monkeypatch, absolute_key):
    f, manifest = _project(tmp_path)
    row = json.loads(manifest.read_text(encoding="utf-8"))["src/mod.py"]
    manifest.write_text(json.dumps({str(f) if absolute_key else "src/mod.py": {
        "mtime": row["mtime"], "hash": row["ast_hash"],
    }}), encoding="utf-8")
    assert _is_deny(_invoke("read", _read(f), tmp_path, monkeypatch, strict=True))


@pytest.mark.parametrize("collision", ["mod.py", "other/src/mod.py", "other/mod.py"])
def test_basename_or_suffix_collision_is_not_extraction_evidence(tmp_path, monkeypatch, collision):
    f, manifest = _project(tmp_path)
    row = json.loads(manifest.read_text(encoding="utf-8"))["src/mod.py"]
    manifest.write_text(json.dumps({collision: row}), encoding="utf-8")
    _assert_stale(f, tmp_path, monkeypatch)


@pytest.mark.parametrize("absolute_out", [False, True])
def test_custom_output_manifest_is_used(tmp_path, monkeypatch, absolute_out):
    f, manifest = _project(tmp_path)
    custom = tmp_path / "artifacts" / "graph"
    custom.parent.mkdir()
    manifest.parent.rename(custom)
    monkeypatch.setattr(paths, "GRAPHIFY_OUT", str(custom) if absolute_out else "artifacts/graph")
    assert _is_deny(_invoke("read", _read(f), tmp_path, monkeypatch, strict=True))


def test_older_file_does_not_hash(tmp_path, monkeypatch):
    import graphify.detect as detect

    f, _ = _project(tmp_path)
    os.utime(f, (50, 50))
    calls = []
    monkeypatch.setattr(detect, "_md5_file", lambda p: calls.append(p) or "")
    assert _is_deny(_invoke("read", _read(f), tmp_path, monkeypatch, strict=True))
    assert calls == []


@pytest.mark.parametrize("failure", ["manifest", "source"])
def test_read_errors_stay_stale(tmp_path, monkeypatch, failure):
    f, manifest = _project(tmp_path)
    if failure == "source":
        import builtins
        original_open = builtins.open

        def denied_source(file, *args, **kwargs):
            # The hashing code opens the source through paths.os_long_path,
            # which on win32 prepends the extended-length \\?\ marker so long
            # paths survive open(). Comparing that prefixed string straight
            # against f as plain Path objects never matches on Windows, so
            # the mock never raised and this branch silently tested the
            # readable-file case instead. Normalize both sides the same way
            # the source itself does before comparing.
            if paths.os_long_path(file) == paths.os_long_path(f):
                raise PermissionError("unreadable fixture")
            return original_open(file, *args, **kwargs)

        monkeypatch.setattr(builtins, "open", denied_source)
    else:
        original = Path.open

        def denied(self, *args, **kwargs):
            if self.resolve() == manifest:
                raise PermissionError("unreadable fixture")
            return original(self, *args, **kwargs)

        monkeypatch.setattr(Path, "open", denied)
    _assert_stale(f, tmp_path, monkeypatch)


def test_stat_index_digest_cannot_bless_changed_extraction(tmp_path, monkeypatch):
    from graphify.cache import file_hash

    f, _ = _project(tmp_path)
    f.write_text("def x():\n    return 2\n", encoding="utf-8")
    file_hash(f, root=tmp_path)  # Memoizes current content, not extracted content.
    _assert_stale(f, tmp_path, monkeypatch)