fix: resolve path format mismatch in folder CLAUDE.md generation (#794) (#813)

The isDirectChild() function failed to match files when the API used
absolute paths (/Users/x/project/app/api) but the database stored
relative paths (app/api/router.py). This caused all folder CLAUDE.md
files to incorrectly show "No recent activity".

Changes:
- Create shared path-utils module with proper path normalization
- Implement suffix matching strategy for mixed path formats
- Update SessionSearch.ts to use shared utilities
- Update regenerate-claude-md.ts to use shared utilities (was using
  outdated broken logic)
- Prevent spurious directory creation from malformed paths
- Add comprehensive test coverage for path matching edge cases

This is the proper fix for #794, replacing PR #809 which only masked
the bug by skipping file creation when "no activity" was shown.

Co-authored-by: bigphoot <bigphoot@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Knigge
2026-01-26 12:48:31 -08:00
committed by GitHub
parent 0b7ecedcd7
commit 182097ef1c
6 changed files with 257 additions and 57 deletions
+13 -34
View File
@@ -43,8 +43,10 @@ interface ObservationRow {
discovery_tokens: number | null;
}
// Import shared formatting utilities
// Import shared utilities
import { formatTime, groupByDate } from '../src/shared/timeline-formatting.js';
import { isDirectChild } from '../src/shared/path-utils.js';
import { replaceTaggedContent } from '../src/utils/claude-md-utils.js';
// Type icon map (matches ModeManager)
const TYPE_ICONS: Record<string, string> = {
@@ -135,19 +137,6 @@ function walkDirectoriesWithIgnore(dir: string, folders: Set<string>, depth: num
}
}
/**
* Check if a file is a direct child of a folder (not in a subfolder)
* @param filePath - File path like "src/services/foo.ts"
* @param folderPath - Folder path like "src/services"
* @returns true if file is directly in folder, false if in a subfolder
*/
function isDirectChild(filePath: string, folderPath: string): boolean {
if (!filePath.startsWith(folderPath + '/')) return false;
const remainder = filePath.slice(folderPath.length + 1);
// If remainder contains a slash, it's in a subfolder
return !remainder.includes('/');
}
/**
* Check if an observation has any files that are direct children of the folder
*/
@@ -288,37 +277,27 @@ function formatObservationsForClaudeMd(observations: ObservationRow[], folderPat
/**
* Write CLAUDE.md file with tagged content preservation
* Note: For the CLI regenerate tool, we DO create directories since the user
* explicitly requested regeneration. This differs from the runtime behavior
* which only writes to existing folders.
*/
function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
function writeClaudeMdToFolderForRegenerate(folderPath: string, newContent: string): void {
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const tempFile = `${claudeMdPath}.tmp`;
// For regenerate CLI, we create the folder if needed
mkdirSync(folderPath, { recursive: true });
// Read existing content if file exists
let existingContent = '';
if (existsSync(claudeMdPath)) {
existingContent = readFileSync(claudeMdPath, 'utf-8');
}
const startTag = '<claude-mem-context>';
const endTag = '</claude-mem-context>';
let finalContent: string;
if (!existingContent) {
finalContent = `${startTag}\n${newContent}\n${endTag}`;
} else {
const startIdx = existingContent.indexOf(startTag);
const endIdx = existingContent.indexOf(endTag);
if (startIdx !== -1 && endIdx !== -1) {
finalContent = existingContent.substring(0, startIdx) +
`${startTag}\n${newContent}\n${endTag}` +
existingContent.substring(endIdx + endTag.length);
} else {
finalContent = existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`;
}
}
// Use shared utility to preserve user content outside tags
const finalContent = replaceTaggedContent(existingContent, newContent);
// Atomic write: temp file + rename
writeFileSync(tempFile, finalContent);
renameSync(tempFile, claudeMdPath);
}
@@ -450,7 +429,7 @@ function regenerateFolder(
// Format using relative path for display, write to absolute path
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
writeClaudeMdToFolder(absoluteFolder, formatted);
writeClaudeMdToFolderForRegenerate(absoluteFolder, formatted);
return { success: true, observationCount: observations.length };
} catch (error) {
+3 -11
View File
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
import { TableNameRow } from '../../types/database.js';
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import { isDirectChild } from '../../shared/path-utils.js';
import {
ObservationSearchResult,
SessionSummarySearchResult,
@@ -336,15 +337,6 @@ export class SessionSearch {
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
}
/**
* Check if a file is a direct child of a folder (not in a subfolder)
*/
private isDirectChild(filePath: string, folderPath: string): boolean {
if (!filePath.startsWith(folderPath + '/')) return false;
const remainder = filePath.slice(folderPath.length + 1);
return !remainder.includes('/');
}
/**
* Check if an observation has any files that are direct children of the folder
*/
@@ -354,7 +346,7 @@ export class SessionSearch {
try {
const files = JSON.parse(filesJson);
if (Array.isArray(files)) {
return files.some(f => this.isDirectChild(f, folderPath));
return files.some(f => isDirectChild(f, folderPath));
}
} catch {}
return false;
@@ -372,7 +364,7 @@ export class SessionSearch {
try {
const files = JSON.parse(filesJson);
if (Array.isArray(files)) {
return files.some(f => this.isDirectChild(f, folderPath));
return files.some(f => isDirectChild(f, folderPath));
}
} catch {}
return false;
+82
View File
@@ -0,0 +1,82 @@
/**
* Shared path utilities for CLAUDE.md file generation
*
* These utilities handle path normalization and matching, particularly
* for comparing absolute and relative paths in folder CLAUDE.md generation.
*
* @see Issue #794 - Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
*/
/**
* Normalize path separators to forward slashes, collapse consecutive slashes,
* and remove trailing slashes.
*
* @example
* normalizePath('app\\api\\router.py') // 'app/api/router.py'
* normalizePath('app//api///router.py') // 'app/api/router.py'
* normalizePath('app/api/') // 'app/api'
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '');
}
/**
* Check if a file is a direct child of a folder (not in a subfolder).
*
* Handles path format mismatches where folderPath may be absolute but
* filePath is stored as relative in the database.
*
* NOTE: This uses suffix matching which assumes both paths are relative to
* the same project root. It may produce false positives if used across
* different project roots, but this is mitigated by project-scoped queries.
*
* @param filePath - Path to the file (e.g., "app/api/router.py" or "/Users/x/project/app/api/router.py")
* @param folderPath - Path to the folder (e.g., "app/api" or "/Users/x/project/app/api")
* @returns true if file is directly in folder, false if in a subfolder or different folder
*
* @example
* // Same format (both relative)
* isDirectChild('app/api/router.py', 'app/api') // true
* isDirectChild('app/api/v1/router.py', 'app/api') // false (in subfolder)
*
* @example
* // Mixed format (absolute folder, relative file) - fixes #794
* isDirectChild('app/api/router.py', '/Users/dev/project/app/api') // true
*/
export function isDirectChild(filePath: string, folderPath: string): boolean {
const normFile = normalizePath(filePath);
const normFolder = normalizePath(folderPath);
// Strategy 1: Direct prefix match (both paths in same format)
if (normFile.startsWith(normFolder + '/')) {
const remainder = normFile.slice(normFolder.length + 1);
return !remainder.includes('/');
}
// Strategy 2: Handle absolute folderPath with relative filePath
// e.g., folderPath="/Users/x/project/app/api" and filePath="app/api/router.py"
const folderSegments = normFolder.split('/');
const fileSegments = normFile.split('/');
if (fileSegments.length < 2) return false; // Need at least folder/file
const fileDir = fileSegments.slice(0, -1).join('/'); // Directory part of file
const fileName = fileSegments[fileSegments.length - 1]; // Actual filename
// Check if folder path ends with the file's directory path
if (normFolder.endsWith('/' + fileDir) || normFolder === fileDir) {
// File is a direct child (no additional subdirectories)
return !fileName.includes('/');
}
// Check if file's directory is contained at the end of folder path
// by progressively checking suffixes
for (let i = 0; i < folderSegments.length; i++) {
const folderSuffix = folderSegments.slice(i).join('/');
if (folderSuffix === fileDir) {
return true;
}
}
return false;
}
+11 -6
View File
@@ -6,7 +6,7 @@
* <claude-mem-context> tags.
*/
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
import path from 'path';
import os from 'os';
import { logger } from './logger.js';
@@ -86,17 +86,22 @@ export function replaceTaggedContent(existingContent: string, newContent: string
/**
* Write CLAUDE.md file to folder with atomic writes.
* Creates directory structure if needed.
* Only writes to existing folders; skips non-existent paths to prevent
* creating spurious directory structures from malformed paths.
*
* @param folderPath - Absolute path to the folder
* @param folderPath - Absolute path to the folder (must already exist)
* @param newContent - Content to write inside tags
*/
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const tempFile = `${claudeMdPath}.tmp`;
// Ensure directory exists
mkdirSync(folderPath, { recursive: true });
// Only write to folders that already exist - never create new directories
// This prevents creating spurious folder structures from malformed paths
if (!existsSync(folderPath)) {
logger.debug('FOLDER_INDEX', 'Skipping non-existent folder', { folderPath });
return;
}
// Read existing content if file exists
let existingContent = '';
@@ -321,7 +326,7 @@ export async function updateFolderClaudeMdFiles(
const formatted = formatTimelineForClaudeMd(result.content[0].text);
// Fix for #758: Don't create new CLAUDE.md files if there's no activity
// Fix for #794: Don't create new CLAUDE.md files if there's no activity
// But update existing ones to show "No recent activity" if they already exist
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const hasNoActivity = formatted.includes('*No recent activity*');
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test';
import { isDirectChild, normalizePath } from '../../../src/shared/path-utils.js';
/**
* Tests for path matching logic, specifically the isDirectChild() algorithm
* Covers fix for issue #794: Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
*
* These tests validate the shared path-utils module which is used by:
* - SessionSearch.ts (runtime folder CLAUDE.md generation)
* - regenerate-claude-md.ts (CLI regeneration tool)
*/
describe('isDirectChild path matching', () => {
describe('same path format', () => {
test('returns true for direct child with relative paths', () => {
expect(isDirectChild('app/api/router.py', 'app/api')).toBe(true);
});
test('returns true for direct child with absolute paths', () => {
expect(isDirectChild('/Users/dev/project/app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
});
test('returns false for files in subdirectory with relative paths', () => {
expect(isDirectChild('app/api/v1/router.py', 'app/api')).toBe(false);
});
test('returns false for files in subdirectory with absolute paths', () => {
expect(isDirectChild('/Users/dev/project/app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
});
test('returns false for unrelated paths', () => {
expect(isDirectChild('lib/utils/helper.py', 'app/api')).toBe(false);
});
});
describe('mixed path formats (absolute folder, relative file) - fixes #794', () => {
test('returns true when absolute folder ends with relative file directory', () => {
// This is the exact bug case from #794
expect(isDirectChild('app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
});
test('returns true for deeply nested folder match', () => {
expect(isDirectChild('src/components/Button.tsx', '/home/user/project/src/components')).toBe(true);
});
test('returns false for files in subdirectory of matched folder', () => {
expect(isDirectChild('app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
});
test('returns false when file path does not match folder suffix', () => {
expect(isDirectChild('lib/api/router.py', '/Users/dev/project/app/api')).toBe(false);
});
});
describe('path normalization', () => {
test('handles Windows backslash paths', () => {
expect(isDirectChild('app\\api\\router.py', 'app\\api')).toBe(true);
});
test('handles mixed slashes', () => {
expect(isDirectChild('app/api\\router.py', 'app\\api')).toBe(true);
});
test('handles trailing slashes on folder path', () => {
expect(isDirectChild('app/api/router.py', 'app/api/')).toBe(true);
});
test('handles double slashes (path normalization bug)', () => {
expect(isDirectChild('app//api/router.py', 'app/api')).toBe(true);
});
test('collapses multiple consecutive slashes', () => {
expect(isDirectChild('app///api///router.py', 'app//api//')).toBe(true);
});
});
describe('edge cases', () => {
test('returns false for single segment file path', () => {
expect(isDirectChild('router.py', '/Users/dev/project/app/api')).toBe(false);
});
test('returns false for empty paths', () => {
expect(isDirectChild('', 'app/api')).toBe(false);
expect(isDirectChild('app/api/router.py', '')).toBe(false);
});
test('handles root-level folders', () => {
expect(isDirectChild('src/file.ts', '/project/src')).toBe(true);
});
test('prevents false positive from partial segment match', () => {
// "api" folder should not match "api-v2" folder
expect(isDirectChild('app/api-v2/router.py', '/Users/dev/project/app/api')).toBe(false);
});
test('handles similar folder names correctly', () => {
// "components" should not match "components-old"
expect(isDirectChild('src/components-old/Button.tsx', '/project/src/components')).toBe(false);
});
});
});
describe('normalizePath', () => {
test('converts backslashes to forward slashes', () => {
expect(normalizePath('app\\api\\router.py')).toBe('app/api/router.py');
});
test('collapses consecutive slashes', () => {
expect(normalizePath('app//api///router.py')).toBe('app/api/router.py');
});
test('removes trailing slashes', () => {
expect(normalizePath('app/api/')).toBe('app/api');
expect(normalizePath('app/api///')).toBe('app/api');
});
test('handles Windows UNC paths', () => {
expect(normalizePath('\\\\server\\share\\file.txt')).toBe('/server/share/file.txt');
});
test('preserves leading slash for absolute paths', () => {
expect(normalizePath('/Users/dev/project')).toBe('/Users/dev/project');
});
});
+24 -6
View File
@@ -147,8 +147,22 @@ describe('formatTimelineForClaudeMd', () => {
});
describe('writeClaudeMdToFolder', () => {
it('should create CLAUDE.md in new folder', () => {
const folderPath = join(tempDir, 'new-folder');
it('should skip non-existent folders (fix for spurious directory creation)', () => {
const folderPath = join(tempDir, 'non-existent-folder');
const content = '# Recent Activity\n\nTest content';
// Should not throw, should silently skip
writeClaudeMdToFolder(folderPath, content);
// Folder and CLAUDE.md should NOT be created
expect(existsSync(folderPath)).toBe(false);
const claudeMdPath = join(folderPath, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(false);
});
it('should create CLAUDE.md in existing folder', () => {
const folderPath = join(tempDir, 'existing-folder');
mkdirSync(folderPath, { recursive: true });
const content = '# Recent Activity\n\nTest content';
writeClaudeMdToFolder(folderPath, content);
@@ -180,20 +194,22 @@ describe('writeClaudeMdToFolder', () => {
expect(fileContent).not.toContain('Old content');
});
it('should create nested directories', () => {
it('should not create nested directories (fix for spurious directory creation)', () => {
const folderPath = join(tempDir, 'deep', 'nested', 'folder');
const content = 'Nested content';
// Should not throw, should silently skip
writeClaudeMdToFolder(folderPath, content);
// Nested directories should NOT be created
const claudeMdPath = join(folderPath, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(true);
expect(existsSync(join(tempDir, 'deep'))).toBe(true);
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
expect(existsSync(claudeMdPath)).toBe(false);
expect(existsSync(join(tempDir, 'deep'))).toBe(false);
});
it('should not leave .tmp file after write (atomic write)', () => {
const folderPath = join(tempDir, 'atomic-test');
mkdirSync(folderPath, { recursive: true });
const content = 'Atomic write test';
writeClaudeMdToFolder(folderPath, content);
@@ -218,6 +234,7 @@ describe('updateFolderClaudeMdFiles', () => {
it('should fetch timeline and write CLAUDE.md', async () => {
const folderPath = join(tempDir, 'api-test');
mkdirSync(folderPath, { recursive: true }); // Folder must exist - we no longer create directories
const filePath = join(folderPath, 'test.ts');
const apiResponse = {
@@ -412,6 +429,7 @@ describe('updateFolderClaudeMdFiles', () => {
it('should write CLAUDE.md to resolved projectRoot path', async () => {
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
mkdirSync(subfolderPath, { recursive: true }); // Folder must exist - we no longer create directories
const apiResponse = {
content: [{