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:
HumbleTurtle
2026-09-28 23:33:22 -03:00
co-authored by Claude Opus 5.5
parent d86610da8e
commit 611a4571f3
5 changed files with 283 additions and 61 deletions
+140 -20
View File
@@ -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 --------------------------------------------------