Skip to content

Commit 9588e8a

Browse files
ithiria894claude
andcommitted
fix: resolve encoded project paths with underscores via DFS backtracking (#17)
Replace greedy path resolver with DFS + backtracking that lists actual directory entries and normalizes underscores before comparing. Fixes silent disappearance of projects whose paths contain underscores. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce39e57 commit 9588e8a

File tree

3 files changed

+185
-26
lines changed

3 files changed

+185
-26
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpware/claude-code-organizer",
3-
"version": "0.10.3",
3+
"version": "0.10.4",
44
"description": "Organize all your Claude Code memories, skills, MCP servers, commands, agents, rules, and hooks — view by scope hierarchy, move between scopes via drag-and-drop",
55
"type": "module",
66
"files": [

src/scanner.mjs

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -159,45 +159,63 @@ async function getSettingsOverrides() {
159159
* at each level by consuming segments from the encoded name.
160160
*/
161161
async function resolveEncodedProjectPath(encoded) {
162-
// Remove leading dash, split by dash
163162
const segments = encoded.replace(/^-/, "").split("-");
164-
let currentPath = "/";
165-
let i = 0;
163+
let rootPath = "/";
164+
let startIdx = 0;
166165

167166
// Windows: encoded paths look like "c--Users-user-Desktop-project"
168167
// The drive letter "c" becomes the first segment, followed by empty string from "--"
169168
// Need to detect and convert to "C:\"
170169
if (platform() === "win32" && segments.length >= 2 && segments[0].length === 1 && segments[1] === "") {
171-
currentPath = segments[0].toUpperCase() + ":\\";
172-
i = 2; // skip drive letter + empty segment
170+
rootPath = segments[0].toUpperCase() + ":\\";
171+
startIdx = 2;
173172
}
174173

175-
while (i < segments.length) {
176-
// Try longest match first: join remaining segments and check if directory exists
177-
let matched = false;
174+
// Normalize for comparison: lowercase, replace _ with -
175+
// Claude Code's encoding replaces both / and _ with -, making it lossy.
176+
// By normalizing both sides we can match "My_Projects" against "My-Projects".
177+
const norm = (s) => s.toLowerCase().replace(/_/g, "-");
178+
179+
// DFS resolver with backtracking — lists actual directory entries at each
180+
// level instead of guessing paths, so underscore/hyphen ambiguity is handled.
181+
async function resolve(currentPath, i) {
182+
if (i >= segments.length) {
183+
return (await exists(currentPath)) ? currentPath : null;
184+
}
185+
186+
let entries;
187+
try {
188+
entries = await readdir(currentPath, { withFileTypes: true });
189+
entries = entries.filter(e => e.isDirectory());
190+
} catch {
191+
return null;
192+
}
193+
194+
// Map normalized directory names → actual names on disk
195+
const entryMap = new Map();
196+
for (const e of entries) {
197+
const key = norm(e.name);
198+
if (!entryMap.has(key)) entryMap.set(key, []);
199+
entryMap.get(key).push(e.name);
200+
}
201+
202+
// Try longest match first, backtrack on failure
178203
for (let end = segments.length; end > i; end--) {
179-
const candidate = segments.slice(i, end).join("-");
180-
const testPath = join(currentPath, candidate);
181-
if (await exists(testPath)) {
182-
const s = await safeStat(testPath);
183-
if (s && s.isDirectory()) {
184-
currentPath = testPath;
185-
i = end;
186-
matched = true;
187-
break;
204+
const candidate = norm(segments.slice(i, end).join("-"));
205+
const matches = entryMap.get(candidate);
206+
if (matches) {
207+
for (const actualName of matches) {
208+
const nextPath = join(currentPath, actualName);
209+
const result = await resolve(nextPath, end);
210+
if (result) return result;
188211
}
189212
}
190213
}
191-
if (!matched) {
192-
// Try single segment
193-
currentPath = join(currentPath, segments[i]);
194-
i++;
195-
}
214+
215+
return null;
196216
}
197217

198-
// Verify the resolved path exists
199-
if (await exists(currentPath)) return currentPath;
200-
return null;
218+
return resolve(rootPath, startIdx);
201219
}
202220

203221
// ── Scope discovery ──────────────────────────────────────────────────

tests/e2e/dashboard.spec.mjs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2942,3 +2942,144 @@ test.describe('Security Scanner UI', () => {
29422942
env.cleanup();
29432943
});
29442944
});
2945+
2946+
// ── Path Resolution — underscore/hyphen ambiguity (#17) ────────────
2947+
2948+
test.describe('Path Resolution', () => {
2949+
2950+
test('project with underscores in path is resolved and visible (#17)', async () => {
2951+
// Claude Code encodes both "/" and "_" as "-", making the encoding lossy.
2952+
// E.g. /tmp/cco-test-xyz/My_Projects/ai_repo → encoded as -tmp-cco-test-xyz-My-Projects-ai-repo
2953+
// The resolver must match "My-Projects" back to "My_Projects" on disk.
2954+
const port = PORT_COUNTER++;
2955+
const tmpDir = await mkdtemp(join(tmpdir(), 'cco-test-'));
2956+
const claudeDir = join(tmpDir, '.claude');
2957+
2958+
// Create a project dir with underscores
2959+
const projectDir = join(tmpDir, 'My_Projects', 'ai_repo');
2960+
await mkdir(projectDir, { recursive: true });
2961+
2962+
// Claude Code's encoding: replace both / and _ with -
2963+
const encodedProject = projectDir.replace(/[/_]/g, '-');
2964+
const projectMemDir = join(claudeDir, 'projects', encodedProject, 'memory');
2965+
await mkdir(projectMemDir, { recursive: true });
2966+
await writeFile(join(projectMemDir, 'MEMORY.md'), '# Memory Index\n');
2967+
await writeFile(join(projectMemDir, 'test_note.md'),
2968+
`---\nname: test_note\ndescription: Test note in underscore project\ntype: project\n---\nThis project has underscores in path.`);
2969+
2970+
// Need at least one global memory for warmup check
2971+
await mkdir(join(claudeDir, 'memory'), { recursive: true });
2972+
await writeFile(join(claudeDir, 'memory', 'MEMORY.md'), '# Memory Index\n');
2973+
await writeFile(join(claudeDir, 'memory', 'dummy.md'),
2974+
`---\nname: dummy\ndescription: dummy\ntype: user\n---\ndummy`);
2975+
2976+
// Start server using cli.mjs (same as createTestEnv)
2977+
let actualPort = port;
2978+
const srv = await new Promise((resolve, reject) => {
2979+
const proc = spawn(NODE_BIN, [join(PROJECT_ROOT, 'bin', 'cli.mjs'), '--port', String(port)], {
2980+
env: { ...process.env, HOME: tmpDir },
2981+
stdio: ['ignore', 'pipe', 'pipe'],
2982+
});
2983+
const timeout = setTimeout(() => reject(new Error('Server start timeout')), 10000);
2984+
proc.stdout.on('data', (data) => {
2985+
const msg = data.toString();
2986+
if (msg.includes('running at')) {
2987+
clearTimeout(timeout);
2988+
const match = msg.match(/localhost:(\d+)/);
2989+
if (match) actualPort = parseInt(match[1], 10);
2990+
resolve(proc);
2991+
}
2992+
});
2993+
proc.on('error', (err) => { clearTimeout(timeout); reject(err); });
2994+
});
2995+
const baseURL = `http://localhost:${actualPort}`;
2996+
2997+
// Warmup
2998+
for (let i = 0; i < 10; i++) {
2999+
try { const r = await (await fetch(`${baseURL}/api/scan`)).json(); if (r.items?.length > 0) break; } catch {}
3000+
await new Promise(r => setTimeout(r, 300));
3001+
}
3002+
3003+
const scanRes = await fetch(`${baseURL}/api/scan`);
3004+
const data = await scanRes.json();
3005+
3006+
// The underscore project should be resolved — look for its memory item
3007+
const underscoreItems = data.items.filter(i =>
3008+
i.scopeId !== 'global' && i.name === 'test_note'
3009+
);
3010+
expect(underscoreItems.length).toBe(1);
3011+
3012+
// The scope should show the real path with underscores, not hyphens
3013+
const scope = data.scopes.find(s => s.repoDir && s.repoDir.includes('My_Projects'));
3014+
expect(scope).toBeTruthy();
3015+
expect(scope.repoDir).toContain('My_Projects');
3016+
expect(scope.repoDir).toContain('ai_repo');
3017+
3018+
srv.kill('SIGKILL');
3019+
await new Promise(r => setTimeout(r, 500));
3020+
await rm(tmpDir, { recursive: true, force: true });
3021+
});
3022+
3023+
test('project with hyphens in path still resolves correctly', async () => {
3024+
// Ensure the fix for underscores doesn't break normal hyphenated paths
3025+
const port = PORT_COUNTER++;
3026+
const tmpDir = await mkdtemp(join(tmpdir(), 'cco-test-'));
3027+
const claudeDir = join(tmpDir, '.claude');
3028+
3029+
const projectDir = join(tmpDir, 'my-company', 'my-repo');
3030+
await mkdir(projectDir, { recursive: true });
3031+
3032+
// Normal encoding: only / replaced with -
3033+
const encodedProject = projectDir.replace(/\//g, '-');
3034+
const projectMemDir = join(claudeDir, 'projects', encodedProject, 'memory');
3035+
await mkdir(projectMemDir, { recursive: true });
3036+
await writeFile(join(projectMemDir, 'MEMORY.md'), '# Memory Index\n');
3037+
await writeFile(join(projectMemDir, 'hyphen_note.md'),
3038+
`---\nname: hyphen_note\ndescription: Test note in hyphenated project\ntype: project\n---\nThis project has hyphens in path.`);
3039+
3040+
await mkdir(join(claudeDir, 'memory'), { recursive: true });
3041+
await writeFile(join(claudeDir, 'memory', 'MEMORY.md'), '# Memory Index\n');
3042+
await writeFile(join(claudeDir, 'memory', 'dummy.md'),
3043+
`---\nname: dummy\ndescription: dummy\ntype: user\n---\ndummy`);
3044+
3045+
let actualPort = port;
3046+
const srv = await new Promise((resolve, reject) => {
3047+
const proc = spawn(NODE_BIN, [join(PROJECT_ROOT, 'bin', 'cli.mjs'), '--port', String(port)], {
3048+
env: { ...process.env, HOME: tmpDir },
3049+
stdio: ['ignore', 'pipe', 'pipe'],
3050+
});
3051+
const timeout = setTimeout(() => reject(new Error('Server start timeout')), 10000);
3052+
proc.stdout.on('data', (data) => {
3053+
const msg = data.toString();
3054+
if (msg.includes('running at')) {
3055+
clearTimeout(timeout);
3056+
const match = msg.match(/localhost:(\d+)/);
3057+
if (match) actualPort = parseInt(match[1], 10);
3058+
resolve(proc);
3059+
}
3060+
});
3061+
proc.on('error', (err) => { clearTimeout(timeout); reject(err); });
3062+
});
3063+
const baseURL = `http://localhost:${actualPort}`;
3064+
3065+
for (let i = 0; i < 10; i++) {
3066+
try { const r = await (await fetch(`${baseURL}/api/scan`)).json(); if (r.items?.length > 0) break; } catch {}
3067+
await new Promise(r => setTimeout(r, 300));
3068+
}
3069+
3070+
const scanRes = await fetch(`${baseURL}/api/scan`);
3071+
const data = await scanRes.json();
3072+
3073+
const hyphenItems = data.items.filter(i =>
3074+
i.scopeId !== 'global' && i.name === 'hyphen_note'
3075+
);
3076+
expect(hyphenItems.length).toBe(1);
3077+
3078+
const scope = data.scopes.find(s => s.repoDir && s.repoDir.includes('my-company'));
3079+
expect(scope).toBeTruthy();
3080+
3081+
srv.kill('SIGKILL');
3082+
await new Promise(r => setTimeout(r, 500));
3083+
await rm(tmpDir, { recursive: true, force: true });
3084+
});
3085+
});

0 commit comments

Comments
 (0)