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

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:
2026-08-04 03:29:13 +00:00
co-authored by Claude Opus 4.8
parent 5add9b0ce0
commit 0191a93eb9
3 changed files with 142 additions and 10 deletions
+68
View File
@@ -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