← Files AkinatorARCHIVED FILE

tests/test_scope.py

8.01 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

"""Tests for change-scoping and the interrupt budget.

This is the stage that decides whether the other six get used. If a full pass
costs the same on a typo as on a release, people stop running it - and that is
how this discipline dies in every repository where it dies.

The property that must not break: **scoping never skips a station.** It reports
which stations have work. A quiet station is still run and simply finds nothing.
A scoper that silently drops stations is an exemption engine, and the whole
design collapses into "document when convenient".
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

import akinator_ledger as led
import akinator_scope as sc


def _scope_of(paths: list[str], monkeypatch, repo: Path) -> sc.Scope:
    monkeypatch.setattr(sc, "changed_paths", lambda r, a="HEAD": paths)
    return sc.scope(repo)


# --------------------------------------------------------------------------
# Scoping never skips a station
# --------------------------------------------------------------------------

def test_every_station_is_accounted_for(monkeypatch, repo: Path) -> None:
    """Awake plus quiet is always the whole loop. A station that appears in
    neither list has been silently dropped, which is the failure mode."""
    for paths in ([], ["README.md"], ["src/billing/refund.ts"],
                  ["db/migrations/0042.sql", "package.json"]):
        result = _scope_of(paths, monkeypatch, repo)
        assert len(result.woken) + len(result.quiet) == len(sc.STATIONS)
        names = {s.name for s in result.woken} | {s.name for s in result.quiet}
        assert names == {s.name for s in sc.STATIONS}


def test_resolve_and_verify_are_never_scoped_away(monkeypatch, repo: Path) -> None:
    """A pass that skips RESOLVE is guessing, and one that skips VERIFY has no
    evidence. Neither is ever optional, whatever changed."""
    for paths in ([], ["a.lock"], ["README.md"]):
        woken = {s.name for s in _scope_of(paths, monkeypatch, repo).woken}
        assert "RESOLVE" in woken
        assert "VERIFY" in woken


def test_business_wakes_on_money(monkeypatch, repo: Path) -> None:
    woken = {s.name for s in _scope_of(
        ["src/billing/refund.ts"], monkeypatch, repo).woken}
    assert "BUSINESS" in woken


def test_business_stays_quiet_on_a_readme(monkeypatch, repo: Path) -> None:
    result = _scope_of(["README.md"], monkeypatch, repo)
    assert "BUSINESS" in {s.name for s in result.quiet}
    assert "DOCUMENT" in {s.name for s in result.woken}


def test_ops_wakes_on_a_migration(monkeypatch, repo: Path) -> None:
    woken = {s.name for s in _scope_of(
        ["db/migrations/0042_add_column.sql"], monkeypatch, repo).woken}
    assert "OPS" in woken


def test_ops_wakes_on_a_lockfile(monkeypatch, repo: Path) -> None:
    """A dependency change is an operational consequence: it needs a rebuild,
    not a restart, and the layer cache will otherwise serve the old set."""
    woken = {s.name for s in _scope_of(
        ["package-lock.json"], monkeypatch, repo).woken}
    assert "OPS" in woken


def test_contextify_wakes_on_a_manifest(monkeypatch, repo: Path) -> None:
    woken = {s.name for s in _scope_of(["pyproject.toml"], monkeypatch, repo).woken}
    assert "CONTEXTIFY" in woken


def test_rule_wakes_only_on_rules(monkeypatch, repo: Path) -> None:
    assert "RULE" in {s.name for s in _scope_of(
        ["rules/12-new.md"], monkeypatch, repo).woken}
    assert "RULE" in {s.name for s in _scope_of(
        ["README.md"], monkeypatch, repo).quiet}


def test_product_wakes_on_a_route(monkeypatch, repo: Path) -> None:
    woken = {s.name for s in _scope_of(
        ["services/api/routes/export.ts"], monkeypatch, repo).woken}
    assert "PRODUCT" in woken


# --------------------------------------------------------------------------
# Trivial changes
# --------------------------------------------------------------------------

def test_a_lockfile_only_change_is_marked_trivial(monkeypatch, repo: Path) -> None:
    result = _scope_of(["yarn.lock"], monkeypatch, repo)
    assert result.trivial


def test_trivial_does_not_mean_skipped(monkeypatch, repo: Path) -> None:
    """The batch still runs every station and records an explicit empty delta.
    'Trivial' describes the work found, not permission to skip looking."""
    result = _scope_of(["logo.png"], monkeypatch, repo)
    assert result.trivial
    assert len(result.woken) + len(result.quiet) == len(sc.STATIONS)
    assert {"RESOLVE", "VERIFY"} <= {s.name for s in result.woken}


def test_a_real_change_alongside_a_lockfile_is_not_trivial(
    monkeypatch, repo: Path
) -> None:
    result = _scope_of(["yarn.lock", "src/billing/refund.ts"], monkeypatch, repo)
    assert not result.trivial
    assert "BUSINESS" in {s.name for s in result.woken}


# --------------------------------------------------------------------------
# The interrupt budget
# --------------------------------------------------------------------------

def test_default_budget_is_fifteen() -> None:
    """The owner wants many questions per prompt (ADR 0010) - grouped into one
    message, ranked, each with a recommended default."""
    assert sc.DEFAULT_INTERRUPT_BUDGET == 15


def test_budget_is_configurable(tmp_path: Path) -> None:
    (tmp_path / ".ai").mkdir()
    (tmp_path / ".ai" / "config.json").write_text(
        json.dumps({"interrupt_budget": 2}), encoding="utf-8"
    )
    assert sc.interrupt_budget(tmp_path) == 2


def test_a_malformed_config_falls_back_to_the_default(tmp_path: Path) -> None:
    (tmp_path / ".ai").mkdir()
    (tmp_path / ".ai" / "config.json").write_text("{ broken", encoding="utf-8")
    assert sc.interrupt_budget(tmp_path) == sc.DEFAULT_INTERRUPT_BUDGET


def _pending(root: Path, count: int) -> None:
    ledger = led.Ledger(root)
    for index in range(count):
        ledger.write(led.Record(
            kind="failure", id=f"pending-{index:03d}-00000",
            title=f"failure number {index}",
            fields={"symptom": "s", "trigger": "t", "root_cause": "r", "fix": "f"},
            occurrences=[f"2026-08-{index + 1:02d} (self-report)",
                         f"2026-09-{index + 1:02d} (git)"],
            sources=["self-report", "git"],
        ))


def test_questions_are_batched_to_the_budget(tmp_path: Path) -> None:
    root = tmp_path / "many"
    root.mkdir()
    _pending(root, 9)

    ask, defer = sc.batched(root, limit=5)
    assert len(ask) == 5
    assert len(defer) == 4
    assert len(ask) + len(defer) == 9, "deferred is not dropped"


def test_deferred_questions_are_kept_not_discarded(tmp_path: Path) -> None:
    root = tmp_path / "kept"
    root.mkdir()
    _pending(root, 7)
    ask, defer = sc.batched(root, limit=3)
    assert {q.text for q in ask}.isdisjoint({q.text for q in defer})
    assert len(sc.pending_questions(root)) == 7


def test_a_decided_recurrence_is_not_asked_again(tmp_path: Path) -> None:
    """The whole point of recording 'neither'."""
    root = tmp_path / "decided"
    root.mkdir()
    _pending(root, 1)
    assert sc.pending_questions(root)

    led.Ledger(root).write(led.Record(
        kind="decision", id="distil-pending-000-00000",
        title="decided", fields={"what": "w", "alternatives": "a", "why": "y"},
    ))
    assert not sc.pending_questions(root)


def test_more_occurrences_rank_a_question_higher(tmp_path: Path) -> None:
    root = tmp_path / "ranked"
    root.mkdir()
    ledger = led.Ledger(root)
    for name, count in (("twice-0000000000", 2), ("five-00000000000", 5)):
        ledger.write(led.Record(
            kind="failure", id=name, title=f"seen {count}",
            fields={"symptom": "s", "trigger": "t", "root_cause": "r", "fix": "f"},
            occurrences=[f"2026-08-{i + 1:02d} (self-report)" for i in range(count)],
            sources=["self-report"],
        ))
    ranked = sc.pending_questions(root)
    assert "seen 5" in ranked[0].text


def test_this_repo_has_no_unanswered_questions(repo: Path) -> None:
    """Akinator must not ship a backlog of its own unanswered questions."""
    ask, defer = sc.batched(repo)
    assert not ask and not defer, [q.text for q in ask + defer]

SHA-256: 1e795726363ba75b92397989452fd87c35f2829de935a31077c6c763fc963381