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
+10 -4
View File
@@ -1,4 +1,5 @@
import importlib.util
import datetime
import hashlib
import json
import pathlib
@@ -513,11 +514,16 @@ def test_manifest_accepts_a_future_compatible_release():
def test_manifest_rejects_a_minimum_contract_newer_than_installer():
module = load_installer()
manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-09-11"
# One day past this installer's own contract, whatever it currently is.
newer = (
datetime.date.fromisoformat(module.CLI_DISTRIBUTION_CONTRACT_VERSION)
+ datetime.timedelta(days=1)
).isoformat()
manifest["contract"]["current"] = newer
manifest["contract"]["minimum_supported"] = newer
for item in manifest["items"]:
item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-09-11"
item["contract_version"] = newer
item["minimum_contract_version"] = newer
try:
module.validated_manifest_items(manifest)
+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 --------------------------------------------------
+52
View File
@@ -12,6 +12,9 @@ sys.path.insert(0, str(SCRIPTS))
from napseer_credentials import CredentialStore, atomic_json, read_json
from test_user_credentials import provider, worker, outcome
# Released CLI 0.4.0 source revision (pinned by the Napseer deploy workflow).
RELEASED_0_4_0 = 'c427e2432ac85038a4f00b2b888bbdb7c851ada1'
@pytest.fixture
def worktrees(tmp_path):
@@ -158,3 +161,52 @@ def test_private_state_cannot_be_redirected_into_checkout(worktrees, monkeypatch
main,_=worktrees
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)
def test_root_tools_file_resolves_like_locator_and_is_invisible_to_migration(worktrees, monkeypatch):
from napseer_credentials import WorkspacePaths
main, _ = worktrees
monkeypatch.delenv('NAPSEER_TOOL_PROFILES', raising=False)
tools = main/'.napseer-tools.json'
committed = b'{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n'
tools.write_bytes(committed)
sub = main/'nested'; sub.mkdir()
# A worker started below the project root finds the file at the root that
# owns .napseer/project.json, the same way it finds the locator.
expression = ("{'path':str(m.project_tools_path()),'profiles':sorted(m.tool_profile_configuration()[0]),"
"'source':m.tool_profile_configuration()[1],"
"'pending':sorted(p.name for p in m.WORKSPACE.migration_sources())}")
result = outcome(worker(sub, expression))['result']
assert result == {'path': str(tools), 'profiles': ['workflow'], 'source': 'tools_file', 'pending': []}
assert set(p.name for p in (main/'.napseer').iterdir()) <= {'project.json', '.gitignore'}
paths = WorkspacePaths(main)
assert paths.migration_sources() == []
paths.require_migrated()
paths.migrate()
assert tools.read_bytes() == committed
def test_root_tools_file_is_invisible_to_released_0_4_0_migration_check(worktrees, tmp_path):
"""CLI 0.4.0 treats any extra .napseer/ entry as private state to migrate."""
import importlib.util
released = subprocess.run(
['git', '-C', str(SCRIPTS.parent.parent), 'show', f'{RELEASED_0_4_0}:resources/scripts/napseer_credentials.py'],
capture_output=True)
if released.returncode != 0:
pytest.skip(f'released 0.4.0 source {RELEASED_0_4_0} is not in this checkout')
source = tmp_path/'napseer_credentials_0_4_0.py'
source.write_bytes(released.stdout)
spec = importlib.util.spec_from_file_location('napseer_credentials_0_4_0', source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
main, _ = worktrees
(main/'.napseer-tools.json').write_text('{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n')
paths = module.WorkspacePaths(main)
assert paths.migration_sources() == []
paths.require_migrated()
# Control: the same file inside .napseer/ would block 0.4.0 clients.
(main/'.napseer/tools.json').write_text('{}\n')
assert [p.name for p in paths.migration_sources()] == ['tools.json']
with pytest.raises(module.CredentialError):
paths.require_migrated()