From 611a4571f3bb3e358b13a664e236d4a70739af44 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Mon, 28 Sep 2026 23:33:22 -0300 Subject: [PATCH] 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) --- resources/scripts/nap_install.py | 4 +- resources/scripts/napseer_mcp_server.py | 114 +++++++++++------ tests/test_cli_command_system.py | 14 ++- tests/test_mcp_reliability_contracts.py | 160 +++++++++++++++++++++--- tests/test_worktree_state.py | 52 ++++++++ 5 files changed, 283 insertions(+), 61 deletions(-) diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index 5e2cd0a..c9d1944 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -66,8 +66,8 @@ RUNTIME_SCRIPT_NAMES = ( "napseer_mcp_supervisor.py", ) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) -CLI_RELEASE_VERSION = "0.4.0" -CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10" +CLI_RELEASE_VERSION = "0.5.0" +CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-29" # Older installers have a fixed asset list and would omit the credentials module. # Reject them before activation; the published bootstrap performs the upgrade. CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10" diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 1a2b2a6..8f83b61 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -326,9 +326,12 @@ AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth PROJECT_LOCATOR_SCHEMA = "napseer.project.v1" PROJECT_LOCATOR_FILENAME = "project.json" PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"} -# Optional, non-identity locator preferences. Values are validated where -# they are consumed so an unknown preference never blocks project identity. -PROJECT_LOCATOR_OPTIONAL_FIELDS = {"tool_profiles"} +# Committed, non-identity tool preferences live at the project root, outside +# .napseer/: the locator schema stays strict and released clients that treat +# other .napseer/ entries as private state awaiting migration never see it. +PROJECT_TOOLS_SCHEMA = "napseer.tools.v1" +PROJECT_TOOLS_FILENAME = ".napseer-tools.json" +PROJECT_TOOLS_MAX_BYTES = 4096 GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json" VAULT_PATH = AUTH_DIR / "vault.json" LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key" @@ -874,7 +877,7 @@ def validate_project_locator(data): "project_locator_invalid", ".napseer/project.json must contain one JSON object.", ) - if not PROJECT_LOCATOR_FIELDS <= set(data) or set(data) - PROJECT_LOCATOR_FIELDS - PROJECT_LOCATOR_OPTIONAL_FIELDS: + if set(data) != PROJECT_LOCATOR_FIELDS: raise SafeToolError( "project_locator_invalid", ".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.", @@ -922,15 +925,12 @@ def validate_project_locator(data): ".napseer/project.json project_slug is invalid.", ) - locator = { + return { "schema": PROJECT_LOCATOR_SCHEMA, "base_url": raw_base_url, "project_id": project_id, "project_slug": project_slug, } - if "tool_profiles" in data: - locator["tool_profiles"] = data["tool_profiles"] - return locator def load_project_locator(): @@ -1048,9 +1048,6 @@ def write_project_locator(project, *, base_url=None): "project_locator_mismatch", "Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.", ) - if existing and "tool_profiles" in existing: - # Preserve the repository's committed preference on rewrite. - desired["tool_profiles"] = existing["tool_profiles"] ensure_project_locator_ignore_policy() path = project_locator_path() @@ -19723,31 +19720,77 @@ REMOVED_TOOL_REPLACEMENTS = { CONFIGURED_TOOL_PROFILES = None -TOOL_PROFILE_LOCATOR_MAX_ITEMS = 16 +TOOL_PROFILE_FILE_MAX_ITEMS = 16 -def locator_tool_profiles(): - """Return (profiles, declared, warnings) from the committed project locator. +def project_tools_path(): + """Committed tool preferences at the project root that owns the locator.""" + return PROJECT_DIR.parent / PROJECT_TOOLS_FILENAME - Unknown or malformed entries are ignored with a warning; a locator that - cannot be read yields no profiles here (identity errors surface elsewhere). + +def read_project_tools_file(): + """Return the parsed committed tools file, None when absent. + + Raises ValueError with a safe message when the file cannot be used. The + file must be a bounded regular JSON file inside the resolved project root. + """ + import stat + + path = project_tools_path() + try: + if not path.exists() and not path.is_symlink(): + return None + if path.is_symlink(): + raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file, not a symlink.") + root = path.parent.resolve() + if path.resolve().parent != root: + raise ValueError(f"{PROJECT_TOOLS_FILENAME} must stay inside the project root.") + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as source: + status = os.fstat(source.fileno()) + if not stat.S_ISREG(status.st_mode): + raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file.") + if status.st_size > PROJECT_TOOLS_MAX_BYTES: + raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.") + body = source.read(PROJECT_TOOLS_MAX_BYTES + 1) + except OSError: + raise ValueError(f"{PROJECT_TOOLS_FILENAME} could not be read.") from None + if len(body) > PROJECT_TOOLS_MAX_BYTES: + raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.") + try: + data = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError): + raise ValueError(f"{PROJECT_TOOLS_FILENAME} is not valid UTF-8 JSON.") from None + if not isinstance(data, dict): + raise ValueError(f"{PROJECT_TOOLS_FILENAME} must contain one JSON object.") + if data.get("schema") != PROJECT_TOOLS_SCHEMA: + raise ValueError(f"{PROJECT_TOOLS_FILENAME} schema must be {PROJECT_TOOLS_SCHEMA}.") + if not isinstance(data.get("tool_profiles"), list): + raise ValueError(f"{PROJECT_TOOLS_FILENAME} tool_profiles must be an array of profile names.") + return data + + +def project_file_tool_profiles(): + """Return (profiles, applied, warnings) from the committed tools file. + + An unusable file is ignored with a warning (core only); unknown profile + names are dropped with a warning. The strict project locator is never + consulted for tool preferences. """ try: - locator = load_project_locator() - except SafeToolError as exc: - return set(), False, [{"code": "project_locator_unreadable", "message": exc.safe_message}] - if not locator or "tool_profiles" not in locator: - return set(), False, [] - raw = locator["tool_profiles"] - if not isinstance(raw, list): - return set(), True, [{ - "code": "invalid_tool_profiles", - "message": ".napseer/project.json tool_profiles must be an array of profile names; it was ignored.", + data = read_project_tools_file() + except ValueError as exc: + return set(), False, [{ + "code": "tools_file_invalid", + "message": f"{exc} It was ignored; only core tools are enabled unless NAPSEER_TOOL_PROFILES is set.", }] + if data is None: + return set(), False, [] + raw = data["tool_profiles"] known = {"all"} | set(TOOL_PROFILES) profiles = set() unknown = [] - for value in raw[:TOOL_PROFILE_LOCATOR_MAX_ITEMS]: + for value in raw[:TOOL_PROFILE_FILE_MAX_ITEMS]: name = value.strip().lower() if isinstance(value, str) else None if name in known: profiles.add(name) @@ -19757,14 +19800,14 @@ def locator_tool_profiles(): if unknown: warnings.append({ "code": "unknown_tool_profile", - "message": ".napseer/project.json tool_profiles contains unknown profile names that were ignored. Supported profiles: " + "message": f"{PROJECT_TOOLS_FILENAME} tool_profiles contains unknown profile names that were ignored. Supported profiles: " + ", ".join(sorted(TOOL_PROFILES)) + ".", "ignored": unknown, }) - if len(raw) > TOOL_PROFILE_LOCATOR_MAX_ITEMS: + if len(raw) > TOOL_PROFILE_FILE_MAX_ITEMS: warnings.append({ "code": "tool_profiles_truncated", - "message": f".napseer/project.json tool_profiles was truncated to {TOOL_PROFILE_LOCATOR_MAX_ITEMS} entries.", + "message": f"{PROJECT_TOOLS_FILENAME} tool_profiles was truncated to {TOOL_PROFILE_FILE_MAX_ITEMS} entries.", }) return profiles, True, warnings @@ -19773,7 +19816,7 @@ def tool_profile_configuration(): """Return (profiles, source, warnings). Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES, - then the committed locator's optional tool_profiles, then core only. + then the committed .napseer-tools.json at the project root, then core only. """ if CONFIGURED_TOOL_PROFILES is not None: configured = set(CONFIGURED_TOOL_PROFILES) @@ -19786,8 +19829,8 @@ def tool_profile_configuration(): source = "environment" warnings = [] else: - configured, declared, warnings = locator_tool_profiles() - source = "project_locator" if declared else "default" + configured, applied, warnings = project_file_tool_profiles() + source = "tools_file" if applied else "default" return configured, source, warnings unknown = set(configured) - ({"all"} | set(TOOL_PROFILES)) if unknown: @@ -20050,7 +20093,8 @@ def contract_profile(args=None): "active": sorted(active_profiles), "source": profile_source, "warnings": profile_warnings, - "configuration": "NAPSEER_TOOL_PROFILES, else .napseer/project.json tool_profiles", + "configuration": "NAPSEER_TOOL_PROFILES, else .napseer-tools.json tool_profiles at the project root, else core", + "tools_file": str(project_tools_path()), "available": { name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items() }, @@ -20926,7 +20970,7 @@ def _call_tool_impl_dispatch(name, args): "tool_profile_required", f"{canonical_name} requires the '{profile}' tool profile. Set " f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, " - f"or add it to tool_profiles in .napseer/project.json.", + f"or add it to tool_profiles in .napseer-tools.json at the project root.", ) if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS: require_unlocked(name) diff --git a/tests/test_cli_command_system.py b/tests/test_cli_command_system.py index 1f0de8e..3e8b1d5 100644 --- a/tests/test_cli_command_system.py +++ b/tests/test_cli_command_system.py @@ -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) diff --git a/tests/test_mcp_reliability_contracts.py b/tests/test_mcp_reliability_contracts.py index 9cde96a..e3ea2cf 100644 --- a/tests/test_mcp_reliability_contracts.py +++ b/tests/test_mcp_reliability_contracts.py @@ -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 -------------------------------------------------- diff --git a/tests/test_worktree_state.py b/tests/test_worktree_state.py index 009df46..1813424 100644 --- a/tests/test_worktree_state.py +++ b/tests/test_worktree_state.py @@ -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()