Move tool profiles to a root file and version the release as 0.5.0
- Tool profiles come from a committed `.napseer-tools.json` at the
project root (schema napseer.tools.v1). The unreleased tool_profiles
field in .napseer/project.json is removed: released clients reject
unknown locator fields, and 0.4.0 treats any extra file inside .napseer/
as private state needing migration (a test loads the released 0.4.0
check from c427e24 to prove the root file is invisible to it).
Environment still overrides; unusable files warn and fall back to core.
- CLI release 0.5.0 with distribution contract 2026-09-29; the minimum
supported contract stays 2026-09-10. The newer-contract rejection test
now derives its date from the installer's own contract.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d86610da8e
commit
611a4571f3
@@ -1,5 +1,5 @@
|
||||
"""Regressions for write idempotency, stale local reads, atomic claim-next,
|
||||
indexed link counts, and locator-declared tool profiles."""
|
||||
indexed link counts, and committed .napseer-tools.json tool profiles."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
@@ -406,7 +406,7 @@ def test_local_discovery_rows_report_indexed_link_counts(mod):
|
||||
assert "links_count" not in summary
|
||||
|
||||
|
||||
# -- 5. Tool profiles from the committed locator ---------------------------
|
||||
# -- 5. Tool profiles from the committed root .napseer-tools.json ----------
|
||||
|
||||
|
||||
def write_locator(mod, **extra):
|
||||
@@ -421,33 +421,153 @@ def write_locator(mod, **extra):
|
||||
}))
|
||||
|
||||
|
||||
def test_locator_tool_profiles_apply_when_env_unset(mod, monkeypatch):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
assert mod.configured_tool_profiles() == set()
|
||||
def write_tools_file(mod, profiles=None, *, raw=None):
|
||||
path = mod.project_tools_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if raw is None:
|
||||
raw = json.dumps({"schema": "napseer.tools.v1", "tool_profiles": profiles}) + "\n"
|
||||
path.write_bytes(raw if isinstance(raw, bytes) else raw.encode("utf-8"))
|
||||
return path
|
||||
|
||||
write_locator(mod, tool_profiles=["workflow", "operations", "bogus"])
|
||||
assert mod.load_project_locator()["project_slug"] == "example"
|
||||
assert mod.configured_tool_profiles() == {"workflow", "operations"}
|
||||
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
||||
|
||||
apropos = mod.explore_tools({})
|
||||
assert apropos["profile_source"] == "project_locator"
|
||||
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
||||
assert apropos["warnings"][0]["ignored"] == ["bogus"]
|
||||
def doctor_warnings(mod):
|
||||
mod.BASE_URL = "https://api.example.test"
|
||||
mod.local_anchor_integrity = lambda: {}
|
||||
mod.index_status = lambda args: {}
|
||||
doctor = mod.doctor({})
|
||||
assert doctor["warnings"][0]["code"] == "unknown_tool_profile"
|
||||
return mod.doctor({}).get("warnings", [])
|
||||
|
||||
|
||||
def test_tools_file_profiles_apply_when_env_unset(mod):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
write_locator(mod)
|
||||
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||
assert mod.project_tools_path() == mod.project_locator_path().parent.parent / ".napseer-tools.json"
|
||||
# The retired in-.napseer location is never read.
|
||||
(mod.project_locator_path().parent / "tools.json").write_text(
|
||||
json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}))
|
||||
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||
|
||||
write_tools_file(mod, ["workflow", " Operations "])
|
||||
assert mod.tool_profile_configuration() == ({"workflow", "operations"}, "tools_file", [])
|
||||
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
||||
apropos = mod.explore_tools({})
|
||||
assert apropos["profile_source"] == "tools_file"
|
||||
assert apropos["active_profiles"] == ["operations", "workflow"]
|
||||
assert "warnings" not in apropos
|
||||
contract = mod.contract_profile({"include_tools": False})["tool_profiles"]
|
||||
assert contract["source"] == "tools_file"
|
||||
assert contract["tools_file"] == str(mod.project_tools_path())
|
||||
assert doctor_warnings(mod) == []
|
||||
# The committed locator still carries identity only.
|
||||
assert set(mod.load_project_locator()) == {"schema", "base_url", "project_id", "project_slug"}
|
||||
|
||||
|
||||
def test_env_overrides_tools_file(mod, monkeypatch):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
write_tools_file(mod, ["workflow"])
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
|
||||
assert mod.configured_tool_profiles() == {"maintenance"}
|
||||
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||
assert mod.explore_tools({})["profile_source"] == "environment"
|
||||
|
||||
monkeypatch.delenv("NAPSEER_TOOL_PROFILES")
|
||||
write_locator(mod, tool_profiles="workflow")
|
||||
assert mod.configured_tool_profiles() == set()
|
||||
assert mod.explore_tools({})["warnings"][0]["code"] == "invalid_tool_profiles"
|
||||
# A non-empty environment value wins without consulting a broken file.
|
||||
write_tools_file(mod, raw="{not json")
|
||||
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||
|
||||
# An empty or blank value is unset: the committed file applies again.
|
||||
write_tools_file(mod, ["workflow"])
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", " ")
|
||||
assert mod.tool_profile_configuration() == ({"workflow"}, "tools_file", [])
|
||||
|
||||
|
||||
def test_tools_file_unknown_profile_names_are_ignored_with_warning(mod):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
write_tools_file(mod, ["workflow", "bogus", 7])
|
||||
profiles, source, warnings = mod.tool_profile_configuration()
|
||||
assert (profiles, source) == ({"workflow"}, "tools_file")
|
||||
assert [w["code"] for w in warnings] == ["unknown_tool_profile"]
|
||||
assert warnings[0]["ignored"] == ["bogus", "7"]
|
||||
apropos = mod.explore_tools({})
|
||||
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
||||
assert [w["code"] for w in doctor_warnings(mod)] == ["unknown_tool_profile"]
|
||||
|
||||
write_tools_file(mod, ["workflow"] * 20)
|
||||
assert [w["code"] for w in mod.tool_profile_configuration()[2]] == ["tool_profiles_truncated"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
["invalid_json", "not_object", "wrong_schema", "missing_schema", "profiles_not_list",
|
||||
"oversized", "not_utf8", "symlink", "directory"],
|
||||
)
|
||||
def test_unusable_tools_file_warns_and_falls_back_to_core(mod, tmp_path_factory, case):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
marker = "synthetic-marker-not-echoed"
|
||||
raw = {
|
||||
"invalid_json": '{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"], ' + marker,
|
||||
"not_object": json.dumps(["workflow", marker]),
|
||||
"wrong_schema": json.dumps({"schema": "napseer.tools.v9", "tool_profiles": ["workflow"]}),
|
||||
"missing_schema": json.dumps({"tool_profiles": ["workflow"]}),
|
||||
"profiles_not_list": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": "workflow"}),
|
||||
"oversized": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}) + " " * 4096,
|
||||
"not_utf8": b'{"schema": "napseer.tools.v1", "tool_profiles": ["\xff"]}',
|
||||
}.get(case)
|
||||
path = mod.project_tools_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if case == "symlink":
|
||||
outside = tmp_path_factory.mktemp("outside-project") / ".napseer-tools.json"
|
||||
outside.write_text(json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["all"]}))
|
||||
path.symlink_to(outside)
|
||||
elif case == "directory":
|
||||
path.mkdir()
|
||||
else:
|
||||
write_tools_file(mod, raw=raw)
|
||||
|
||||
profiles, source, warnings = mod.tool_profile_configuration()
|
||||
assert (profiles, source) == (set(), "default")
|
||||
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||
assert marker not in warnings[0]["message"]
|
||||
assert mod.enabled_tool_names() == set(mod.CORE_TOOL_NAMES)
|
||||
assert mod.explore_tools({})["warnings"][0]["code"] == "tools_file_invalid"
|
||||
assert [w["code"] for w in doctor_warnings(mod)] == ["tools_file_invalid"]
|
||||
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||
mod._call_tool_impl_dispatch("nap_kanban_claim_next", {})
|
||||
assert excinfo.value.code == "tool_profile_required"
|
||||
assert ".napseer-tools.json" in excinfo.value.safe_message
|
||||
|
||||
|
||||
def test_tools_file_parser_recursion_is_contained(mod, monkeypatch):
|
||||
# Older Python JSON decoders raise RecursionError on deep nesting that
|
||||
# still fits the size bound; it must degrade to core, not fail every call.
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
write_tools_file(mod, ["workflow"])
|
||||
|
||||
def too_deep(*_args, **_kwargs):
|
||||
raise RecursionError("synthetic")
|
||||
|
||||
with monkeypatch.context() as patched:
|
||||
patched.setattr(mod.json, "loads", too_deep)
|
||||
profiles, source, warnings = mod.tool_profile_configuration()
|
||||
assert (profiles, source) == (set(), "default")
|
||||
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||
|
||||
|
||||
def test_locator_tool_profiles_field_is_still_rejected(mod):
|
||||
# tool_profiles was never a released locator field: the locator keeps its
|
||||
# exact-field schema and the field is neither accepted nor applied.
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
write_locator(mod, tool_profiles=["workflow"])
|
||||
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||
mod.load_project_locator()
|
||||
assert excinfo.value.code == "project_locator_invalid"
|
||||
assert "unsupported or missing fields" in excinfo.value.safe_message
|
||||
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||
mod.write_project_locator({"id": "11111111-1111-4111-8111-111111111111", "slug": "example"},
|
||||
base_url="https://api.example.test")
|
||||
assert excinfo.value.code == "project_locator_invalid"
|
||||
assert "tool_profiles" in json.loads(mod.project_locator_path().read_text())
|
||||
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||
|
||||
|
||||
# -- 6. Change feed cursor --------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user