@@ -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 ( / l o c a l h o s t : ( \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 ( / l o c a l h o s t : ( \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