fix: disambiguate project profiles that share a directory basename (#74)
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 19s
CI / Catalog signature (pull_request) Successful in 11s
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 19s
CI / Catalog signature (pull_request) Successful in 11s
discover_project_configs labelled every project by basename alone, so
~/work/app/.mcp.json and ~/personal/app/.mcp.json both read as
"Project: app". Paths dedupe correctly, so both profiles existed -- they
were just indistinguishable in the picker, and picking the wrong one meant
editing, backing up, and writing the wrong repo's config. api/web/app/
server/client as repo names make this common.
- disambiguate_project_labels(dirs): pure, testable. Labels stay
"Project: <name>" until a basename collides, then only the colliding
ones widen toward the root one component at a time
("Project: work/app" vs "Project: personal/app"), widening further if the
parent also collides. The common no-collision case is unchanged.
- Full path goes in the combo item's ToolTipRole, so a profile is always
verifiable by hover regardless of label.
- Tightened the "is this a project config?" check: the docstring claimed a
top-level object but the code only checked is_file(), so a .mcp.json that
was a JSON array or garbage still became a profile and only failed on
open. It now must parse (strict) to a dict, else it's skipped.
Tests: +6 (no-collision basename, colliding widen, deeper widen, and
discover_project_configs disambiguation + skipping array/garbage/missing).
463 passed, ruff clean.
Closes #74
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
This commit is contained in:
@@ -2991,3 +2991,71 @@ def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
|
||||
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
|
||||
assert "servers" in changed_keys
|
||||
assert diff # a server-section change under the custom key is diffed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Project profile label disambiguation (issue #74)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_disambiguate_labels_no_collision_uses_basename():
|
||||
dirs = [Path("/home/me/work/api"), Path("/home/me/work/web")]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
assert labels[Path("/home/me/work/api")] == "Project: api"
|
||||
assert labels[Path("/home/me/work/web")] == "Project: web"
|
||||
|
||||
|
||||
def test_disambiguate_labels_widens_only_colliding_basenames():
|
||||
dirs = [
|
||||
Path("/home/me/work/app"),
|
||||
Path("/home/me/personal/app"),
|
||||
Path("/home/me/notes"),
|
||||
]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
# the two "app"s widen by one parent; the unique "notes" stays plain
|
||||
assert labels[Path("/home/me/work/app")] == "Project: work/app"
|
||||
assert labels[Path("/home/me/personal/app")] == "Project: personal/app"
|
||||
assert labels[Path("/home/me/notes")] == "Project: notes"
|
||||
|
||||
|
||||
def test_disambiguate_labels_widens_further_when_parent_also_collides():
|
||||
dirs = [Path("/a/x/app"), Path("/b/x/app")]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
assert labels[Path("/a/x/app")] == "Project: a/x/app"
|
||||
assert labels[Path("/b/x/app")] == "Project: b/x/app"
|
||||
|
||||
|
||||
def _write_project(tmp_path, name, mcp_content):
|
||||
d = tmp_path / name
|
||||
d.mkdir(parents=True)
|
||||
if mcp_content is not None:
|
||||
(d / ".mcp.json").write_text(mcp_content, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _claude_json_with_projects(tmp_path, dirs):
|
||||
cj = tmp_path / ".claude.json"
|
||||
cj.write_text(json.dumps({"projects": {str(d): {} for d in dirs}}), encoding="utf-8")
|
||||
return cj
|
||||
|
||||
|
||||
def test_discover_project_configs_disambiguates_same_basename(tmp_path):
|
||||
d1 = _write_project(tmp_path / "work", "app", '{"mcpServers": {}}')
|
||||
d2 = _write_project(tmp_path / "personal", "app", '{"mcpServers": {}}')
|
||||
cj = _claude_json_with_projects(tmp_path, [d1, d2])
|
||||
profiles = c.discover_project_configs(cj)
|
||||
labels = sorted(p.label for p in profiles)
|
||||
assert labels == ["Project: personal/app", "Project: work/app"]
|
||||
|
||||
|
||||
def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
|
||||
good = _write_project(tmp_path, "good", '{"mcpServers": {}}')
|
||||
array = _write_project(tmp_path, "arr", "[1, 2, 3]")
|
||||
garbage = _write_project(tmp_path, "junk", "not json at all")
|
||||
missing = tmp_path / "nofile"
|
||||
missing.mkdir()
|
||||
cj = _claude_json_with_projects(tmp_path, [good, array, garbage, missing])
|
||||
profiles = c.discover_project_configs(cj)
|
||||
paths = {str(p.path) for p in profiles}
|
||||
assert str(good / ".mcp.json") in paths
|
||||
assert str(array / ".mcp.json") not in paths
|
||||
assert str(garbage / ".mcp.json") not in paths
|
||||
assert str(missing / ".mcp.json") not in paths
|
||||
|
||||
Reference in New Issue
Block a user