diff --git a/scripts/regenerate-claude-md.ts b/scripts/regenerate-claude-md.ts index b032d65f..8bddd71d 100644 --- a/scripts/regenerate-claude-md.ts +++ b/scripts/regenerate-claude-md.ts @@ -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 = { @@ -135,19 +137,6 @@ function walkDirectoriesWithIgnore(dir: string, folders: Set, 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 = ''; - const endTag = ''; - - 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) { diff --git a/src/services/sqlite/SessionSearch.ts b/src/services/sqlite/SessionSearch.ts index 4c78845c..9b21ff54 100644 --- a/src/services/sqlite/SessionSearch.ts +++ b/src/services/sqlite/SessionSearch.ts @@ -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; diff --git a/src/shared/path-utils.ts b/src/shared/path-utils.ts new file mode 100644 index 00000000..ff04fe5c --- /dev/null +++ b/src/shared/path-utils.ts @@ -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; +} diff --git a/src/utils/claude-md-utils.ts b/src/utils/claude-md-utils.ts index a312127c..a60f5a84 100644 --- a/src/utils/claude-md-utils.ts +++ b/src/utils/claude-md-utils.ts @@ -6,7 +6,7 @@ * 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*'); diff --git a/tests/services/sqlite/session-search-path-matching.test.ts b/tests/services/sqlite/session-search-path-matching.test.ts new file mode 100644 index 00000000..9f40fa2f --- /dev/null +++ b/tests/services/sqlite/session-search-path-matching.test.ts @@ -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'); + }); +}); diff --git a/tests/utils/claude-md-utils.test.ts b/tests/utils/claude-md-utils.test.ts index 44d1c02f..31726a24 100644 --- a/tests/utils/claude-md-utils.test.ts +++ b/tests/utils/claude-md-utils.test.ts @@ -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: [{