From 7c91fe1e4b49d30c88f5a3d433ed0dd9cee8545d Mon Sep 17 00:00:00 2001 From: chopratejas Date: Tue, 14 Apr 2026 15:59:49 -0700 Subject: [PATCH] Fix `headroom learn` failing on project paths with underscores (#159) _component_tokenizations only split on `-` and `.`, so directory names like `my_project` could never be reconstructed from the dash-encoded slug. Add `_` as a separator so the greedy decoder matches snake_case directory names correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- headroom/learn/plugins/claude.py | 8 ++-- tests/test_learn/test_scanner.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index df9e7caac..049b57788 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -375,9 +375,9 @@ def _component_tokenizations(component: str) -> list[list[str]]: add([component]) - for separator in ("-", ".", None): + for separator in ("-", ".", "_", None): if separator is None: - tokens = [token for token in re.split(r"[-.]", component) if token] + tokens = [token for token in re.split(r"[-._]", component) if token] else: tokens = [token for token in component.split(separator) if token] add(tokens) @@ -385,9 +385,9 @@ def _component_tokenizations(component: str) -> list[list[str]]: if component.startswith(".") and len(component) > 1: hidden_component = component[1:] add(["", hidden_component]) - for separator in ("-", ".", None): + for separator in ("-", ".", "_", None): if separator is None: - tokens = [token for token in re.split(r"[-.]", hidden_component) if token] + tokens = [token for token in re.split(r"[-._]", hidden_component) if token] else: tokens = [token for token in hidden_component.split(separator) if token] add(["", *tokens]) diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 0739c9a96..48aaa003d 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -103,6 +103,38 @@ class TestGreedyPathDecode: result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"]) assert result == tmp_path / "my-cool-project.nosync" / "headroom" + # ---- Underscore tests (issue #159) ---- + + def test_single_underscore_in_dirname(self, tmp_path: Path) -> None: + """Directory name contains one literal underscore (e.g. my_project).""" + _make_dirs(tmp_path, "my_project") + result = _greedy_path_decode(tmp_path, ["my", "project"]) + assert result == tmp_path / "my_project" + + def test_multiple_underscores_in_dirname(self, tmp_path: Path) -> None: + """Directory name contains multiple underscores (e.g. my_cool_project).""" + _make_dirs(tmp_path, "my_cool_project") + result = _greedy_path_decode(tmp_path, ["my", "cool", "project"]) + assert result == tmp_path / "my_cool_project" + + def test_underscore_nested_path(self, tmp_path: Path) -> None: + """Nested path like org/my_project should decode correctly.""" + _make_dirs(tmp_path, "org/my_project") + result = _greedy_path_decode(tmp_path, ["org", "my", "project"]) + assert result == tmp_path / "org" / "my_project" + + def test_mixed_underscore_and_hyphen_in_dirname(self, tmp_path: Path) -> None: + """Directory with both hyphens and underscores (e.g. my-cool_project).""" + _make_dirs(tmp_path, "my-cool_project") + result = _greedy_path_decode(tmp_path, ["my", "cool", "project"]) + assert result == tmp_path / "my-cool_project" + + def test_underscore_dir_containing_hyphen_subdir(self, tmp_path: Path) -> None: + """Path like my_app/sub-module — underscore parent + hyphen child.""" + _make_dirs(tmp_path, "my_app/sub-module") + result = _greedy_path_decode(tmp_path, ["my", "app", "sub", "module"]) + assert result == tmp_path / "my_app" / "sub-module" + def test_nonexistent_path_returns_none(self, tmp_path: Path) -> None: result = _greedy_path_decode(tmp_path, ["does", "not", "exist"]) assert result is None @@ -236,6 +268,38 @@ class TestDecodeProjectPath: else: assert result is None or result == project + def test_underscore_dirname_via_greedy(self, users_tmp: Path) -> None: + """my_project — underscore in directory name (issue #159). + + Claude Code encodes /Users/foo/org/my_project as + -Users-foo-org-my-project. Simple replace gives + …/org/my/project which does not exist, so the greedy decoder + must reconstruct my_project from tokens ['my', 'project']. + """ + project = users_tmp / "org" / "my_project" + project.mkdir(parents=True) + + encoded = "-" + str(project)[1:].replace("/", "-") + result = _decode_project_path(encoded) + + if str(users_tmp).startswith("/Users/"): + assert result == project + else: + assert result is None or result == project + + def test_multi_underscore_dirname_via_greedy(self, users_tmp: Path) -> None: + """my_cool_project — multiple underscores (issue #159).""" + project = users_tmp / "my_cool_project" + project.mkdir(parents=True) + + encoded = "-" + str(project)[1:].replace("/", "-") + result = _decode_project_path(encoded) + + if str(users_tmp).startswith("/Users/"): + assert result == project + else: + assert result is None or result == project + def test_windows_drive_letter_pattern(self) -> None: """Encoded name -C-MQ2-macros should detect Windows drive letter.""" import sys