CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s
Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic link the milestone promised existed only as a column. set_family_topic links or unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the version does not move. get_family_idea and get_family_adoption list the topic's rules; list_family_ideas and the Family page name the topic. The model docstrings no longer claim the topic's rules are platform-scoped in retrieval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
8.3 KiB
Python
187 lines
8.3 KiB
Python
"""The adoption ledger without a database (milestone 463 step 4).
|
|
|
|
The parts that decide are pure and pinned here: what an assessment needs
|
|
before it can be recorded, what each ground of the conflict order needs —
|
|
including that every ground ABOVE the deciding one was said not to apply —
|
|
when an answer needs a recheck, how a losing side is folded into the note,
|
|
and which reference an owed task points at. Beside them: the outcomes and
|
|
the order the agent is told are the ones enforced. The state machine against
|
|
Postgres is in tests/test_integration_family_adoption.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
|
from scribe.services.family_adoption import (
|
|
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
|
|
assessment_problems, conflict_problems, needs_recheck,
|
|
)
|
|
from tests.helpers import tool_doc
|
|
|
|
|
|
# --- what an assessment needs ------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("outcome", OUTCOME_KEYS)
|
|
def test_every_outcome_needs_a_reason(outcome):
|
|
problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"])
|
|
assert len(problems) == 1 and outcome in problems[0]
|
|
|
|
|
|
def test_adopted_needs_evidence_naming_where():
|
|
assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [
|
|
"adopted needs evidence naming where the project does it"]
|
|
assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != []
|
|
assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == []
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed"))
|
|
def test_the_other_outcomes_need_no_evidence(outcome):
|
|
assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == []
|
|
|
|
|
|
def test_an_unknown_outcome_is_refused_by_name():
|
|
problems = assessment_problems(outcome="ignored", reason="x", evidence=[])
|
|
assert problems and all(k in problems[0] for k in OUTCOME_KEYS)
|
|
|
|
|
|
# --- the conflict order, branch by branch ----------------------------------------
|
|
|
|
def _checked(upto: str) -> dict:
|
|
return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]}
|
|
|
|
|
|
GOOD = {
|
|
"operator_stance": dict(evidence=["rule: one keystore per app"]),
|
|
"covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]),
|
|
"split_by_condition": dict(conditions={"canon": "the app self-updates",
|
|
"other": "a store installs it"}),
|
|
"most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("ground", CONFLICT_KEYS)
|
|
def test_each_ground_holds_when_its_needs_are_met(ground):
|
|
kw = {"evidence": None, "conditions": None, **GOOD[ground]}
|
|
assert conflict_problems(ground=ground, grounds_checked=_checked(ground),
|
|
fold="their reasoning", **kw) == []
|
|
|
|
|
|
@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:])
|
|
def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground):
|
|
above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
|
|
for skipped in above:
|
|
checked = {k: v for k, v in _checked(ground).items() if k != skipped}
|
|
problems = conflict_problems(ground=ground, grounds_checked=checked,
|
|
fold="x", **{"evidence": None, "conditions": None,
|
|
**GOOD[ground]})
|
|
assert len(problems) == 1 and f"'{skipped}'" in problems[0]
|
|
|
|
|
|
def test_the_first_ground_needs_nothing_checked_above_it():
|
|
assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x",
|
|
evidence=["rule 4"], conditions=None) == []
|
|
|
|
|
|
@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete"))
|
|
def test_grounds_one_two_and_four_need_their_evidence(ground):
|
|
problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x",
|
|
evidence=[" "], conditions=None)
|
|
assert len(problems) == 1 and "evidence" in problems[0]
|
|
|
|
|
|
def test_a_split_needs_both_conditions():
|
|
for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}):
|
|
problems = conflict_problems(ground="split_by_condition",
|
|
grounds_checked=_checked("split_by_condition"),
|
|
fold="x", evidence=None, conditions=conds)
|
|
assert len(problems) == 1 and "conditions" in problems[0]
|
|
|
|
|
|
def test_the_losing_reasoning_is_required():
|
|
problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ",
|
|
evidence=["rule 4"], conditions=None)
|
|
assert len(problems) == 1 and "never dropped" in problems[0]
|
|
|
|
|
|
def test_an_unknown_ground_names_the_order():
|
|
problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x",
|
|
evidence=[], conditions=None)
|
|
assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
|
|
|
|
|
|
# --- recheck -------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [
|
|
("adopted", 1, "canon", 2, True),
|
|
("owed", 1, "canon", 2, True),
|
|
("adopted", 2, "canon", 2, False),
|
|
# An undone revision leaves answers AHEAD of the idea: still not agreeing.
|
|
("adopted", 3, "canon", 2, True),
|
|
("unassessed", None, "canon", 2, False),
|
|
("adopted", 1, "retired", 2, False),
|
|
])
|
|
def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected):
|
|
assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected
|
|
|
|
|
|
# --- folding the losing side into the note ------------------------------------------
|
|
|
|
@pytest.mark.parametrize("ground,heading", [
|
|
("operator_stance", "### Alternative — two's approach"),
|
|
("covers_failure", "### Trap — two's approach"),
|
|
("most_recent_complete", "### Alternative — two's approach"),
|
|
("split_by_condition", "### When a store installs it — two's approach"),
|
|
])
|
|
def test_the_losing_side_is_folded_as_its_ground_says(ground, heading):
|
|
text = _fold_section(ground=ground, fold="Their reasoning.",
|
|
conditions=GOOD["split_by_condition"]["conditions"],
|
|
canon_title="one", other_title="two", when="2026-10-06")
|
|
assert text.startswith(heading)
|
|
assert "Their reasoning." in text and "one's approach" in text
|
|
|
|
|
|
# --- the reference an owed task points at -----------------------------------------
|
|
|
|
REFS = [
|
|
{"id": 10, "title": "Update installer", "language": "kotlin"},
|
|
{"id": 11, "title": "Update installer", "language": "dart"},
|
|
]
|
|
|
|
|
|
def test_the_reference_in_the_projects_language_is_named():
|
|
line = _reference_line(REFS, ["dart"], idea_id=5)
|
|
assert "#11" in line and "#10" not in line
|
|
|
|
|
|
def test_no_reference_in_the_language_names_the_ones_that_exist():
|
|
line = _reference_line(REFS, ["python"], idea_id=5)
|
|
assert "python" in line and "#10" in line and "#11" in line
|
|
|
|
|
|
def test_no_reference_at_all_points_at_the_idea():
|
|
assert "#5" in _reference_line([], ["python"], idea_id=5)
|
|
|
|
|
|
# --- the product text and the doors ---------------------------------------------------
|
|
|
|
def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
|
|
"""Rule 119: the outcomes and the order are product text. The tool
|
|
docstrings must name every outcome and every ground the service checks,
|
|
in the service's order."""
|
|
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
|
|
for n, key in enumerate(OUTCOME_KEYS, start=1):
|
|
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
|
|
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
|
|
for n, key in enumerate(CONFLICT_KEYS, start=1):
|
|
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
|
|
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
|
|
|
|
|
|
def test_every_adoption_tool_is_classified():
|
|
reads = {"get_family_adoption", "list_family_adoptions"}
|
|
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
|
|
"set_family_references", "set_family_topic"}
|
|
assert reads <= _READ_ONLY_TOOLS
|
|
assert writes <= _WRITE_TOOLS
|