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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: [{
|
||||
|
||||
Reference in New Issue
Block a user