Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd619229b2 | |||
| 182097ef1c | |||
| 0b7ecedcd7 | |||
| da01e4bba0 | |||
| 7c3bfadd5e | |||
| a8bb625513 |
@@ -10,7 +10,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "9.0.8",
|
"version": "9.0.10",
|
||||||
"source": "./plugin",
|
"source": "./plugin",
|
||||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-12
@@ -2,6 +2,51 @@
|
|||||||
|
|
||||||
All notable changes to claude-mem.
|
All notable changes to claude-mem.
|
||||||
|
|
||||||
|
## [v9.0.9] - 2026-01-26
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Prevent Creation of Empty CLAUDE.md Files (#809)
|
||||||
|
|
||||||
|
Previously, claude-mem would create new `CLAUDE.md` files in project directories even when there was no activity to display, cluttering codebases with empty context files showing only "*No recent activity*".
|
||||||
|
|
||||||
|
**What changed:** The `updateFolderClaudeMdFiles` function now checks if the formatted content contains no activity before writing. If a `CLAUDE.md` file doesn't already exist and there's nothing to show, it will be skipped entirely. Existing files will still be updated to reflect "No recent activity" if that's the current state.
|
||||||
|
|
||||||
|
**Impact:** Cleaner project directories - only folders with actual activity will have `CLAUDE.md` context files created.
|
||||||
|
|
||||||
|
Thanks to @maxmillienjr for this contribution!
|
||||||
|
|
||||||
|
## [v9.0.8] - 2026-01-26
|
||||||
|
|
||||||
|
## Fix: Prevent Zombie Process Accumulation (Issue #737)
|
||||||
|
|
||||||
|
This release fixes a critical issue where Claude haiku subprocesses spawned by the SDK weren't terminating properly, causing zombie process accumulation. One user reported 155 processes consuming 51GB RAM.
|
||||||
|
|
||||||
|
### Root Causes Addressed
|
||||||
|
- SDK's SpawnedProcess interface hides subprocess PIDs
|
||||||
|
- `deleteSession()` didn't verify subprocess exit
|
||||||
|
- `abort()` was fire-and-forget with no confirmation
|
||||||
|
- No mechanism to track or clean up orphaned processes
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
- **ProcessRegistry module**: Tracks spawned Claude subprocesses via PID
|
||||||
|
- **Custom spawn**: Uses SDK's `spawnClaudeCodeProcess` option to capture PIDs
|
||||||
|
- **Signal propagation**: Passes signal parameter to enable AbortController integration
|
||||||
|
- **Graceful shutdown**: Waits for subprocess exit in `deleteSession()` with 5s timeout
|
||||||
|
- **SIGKILL escalation**: Force-kills processes that don't exit gracefully
|
||||||
|
- **Orphan reaper**: Safety net running every 5 minutes to clean up any missed processes
|
||||||
|
- **Race detection**: Warns about multiple processes per session (race condition indicator)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `src/services/worker/ProcessRegistry.ts` (new): PID registry and reaper
|
||||||
|
- `src/services/worker/SDKAgent.ts`: Use custom spawn to capture PIDs
|
||||||
|
- `src/services/worker/SessionManager.ts`: Verify subprocess exit on delete
|
||||||
|
- `src/services/worker-service.ts`: Start/stop orphan reaper
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.7...v9.0.8
|
||||||
|
|
||||||
|
Fixes #737
|
||||||
|
|
||||||
## [v9.0.6] - 2026-01-22
|
## [v9.0.6] - 2026-01-22
|
||||||
|
|
||||||
## Windows Console Popup Fix
|
## Windows Console Popup Fix
|
||||||
@@ -1261,15 +1306,3 @@ This represents a major reliability improvement for Windows users, eliminating c
|
|||||||
|
|
||||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.4...v7.3.5
|
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.4...v7.3.5
|
||||||
|
|
||||||
## [v7.3.4] - 2025-12-17
|
|
||||||
|
|
||||||
Patch release for bug fixes and minor improvements
|
|
||||||
|
|
||||||
## [v7.3.3] - 2025-12-16
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- Remove all better-sqlite3 references from codebase (#357)
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.2...v7.3.3
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "9.0.8",
|
"version": "9.0.10",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "9.0.8",
|
"version": "9.0.10",
|
||||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman"
|
"name": "Alex Newman"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem-plugin",
|
"name": "claude-mem-plugin",
|
||||||
"version": "9.0.8",
|
"version": "9.0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -43,8 +43,10 @@ interface ObservationRow {
|
|||||||
discovery_tokens: number | null;
|
discovery_tokens: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import shared formatting utilities
|
// Import shared utilities
|
||||||
import { formatTime, groupByDate } from '../src/shared/timeline-formatting.js';
|
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)
|
// Type icon map (matches ModeManager)
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
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
|
* 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
|
* 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 claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||||
const tempFile = `${claudeMdPath}.tmp`;
|
const tempFile = `${claudeMdPath}.tmp`;
|
||||||
|
|
||||||
|
// For regenerate CLI, we create the folder if needed
|
||||||
mkdirSync(folderPath, { recursive: true });
|
mkdirSync(folderPath, { recursive: true });
|
||||||
|
|
||||||
|
// Read existing content if file exists
|
||||||
let existingContent = '';
|
let existingContent = '';
|
||||||
if (existsSync(claudeMdPath)) {
|
if (existsSync(claudeMdPath)) {
|
||||||
existingContent = readFileSync(claudeMdPath, 'utf-8');
|
existingContent = readFileSync(claudeMdPath, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTag = '<claude-mem-context>';
|
// Use shared utility to preserve user content outside tags
|
||||||
const endTag = '</claude-mem-context>';
|
const finalContent = replaceTaggedContent(existingContent, newContent);
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Atomic write: temp file + rename
|
||||||
writeFileSync(tempFile, finalContent);
|
writeFileSync(tempFile, finalContent);
|
||||||
renameSync(tempFile, claudeMdPath);
|
renameSync(tempFile, claudeMdPath);
|
||||||
}
|
}
|
||||||
@@ -450,7 +429,7 @@ function regenerateFolder(
|
|||||||
|
|
||||||
// Format using relative path for display, write to absolute path
|
// Format using relative path for display, write to absolute path
|
||||||
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
|
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
|
||||||
writeClaudeMdToFolder(absoluteFolder, formatted);
|
writeClaudeMdToFolderForRegenerate(absoluteFolder, formatted);
|
||||||
|
|
||||||
return { success: true, observationCount: observations.length };
|
return { success: true, observationCount: observations.length };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
|
|||||||
import { TableNameRow } from '../../types/database.js';
|
import { TableNameRow } from '../../types/database.js';
|
||||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { isDirectChild } from '../../shared/path-utils.js';
|
||||||
import {
|
import {
|
||||||
ObservationSearchResult,
|
ObservationSearchResult,
|
||||||
SessionSummarySearchResult,
|
SessionSummarySearchResult,
|
||||||
@@ -336,15 +337,6 @@ export class SessionSearch {
|
|||||||
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
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
|
* Check if an observation has any files that are direct children of the folder
|
||||||
*/
|
*/
|
||||||
@@ -354,7 +346,7 @@ export class SessionSearch {
|
|||||||
try {
|
try {
|
||||||
const files = JSON.parse(filesJson);
|
const files = JSON.parse(filesJson);
|
||||||
if (Array.isArray(files)) {
|
if (Array.isArray(files)) {
|
||||||
return files.some(f => this.isDirectChild(f, folderPath));
|
return files.some(f => isDirectChild(f, folderPath));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return false;
|
return false;
|
||||||
@@ -372,7 +364,7 @@ export class SessionSearch {
|
|||||||
try {
|
try {
|
||||||
const files = JSON.parse(filesJson);
|
const files = JSON.parse(filesJson);
|
||||||
if (Array.isArray(files)) {
|
if (Array.isArray(files)) {
|
||||||
return files.some(f => this.isDirectChild(f, folderPath));
|
return files.some(f => isDirectChild(f, folderPath));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return false;
|
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.
|
* <claude-mem-context> tags.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -76,8 +76,8 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
|||||||
|
|
||||||
if (startIdx !== -1 && endIdx !== -1) {
|
if (startIdx !== -1 && endIdx !== -1) {
|
||||||
return existingContent.substring(0, startIdx) +
|
return existingContent.substring(0, startIdx) +
|
||||||
`${startTag}\n${newContent}\n${endTag}` +
|
`${startTag}\n${newContent}\n${endTag}` +
|
||||||
existingContent.substring(endIdx + endTag.length);
|
existingContent.substring(endIdx + endTag.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no tags exist, append tagged content at end
|
// If no tags exist, append tagged content at end
|
||||||
@@ -86,17 +86,22 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Write CLAUDE.md file to folder with atomic writes.
|
* 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
|
* @param newContent - Content to write inside tags
|
||||||
*/
|
*/
|
||||||
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||||
const tempFile = `${claudeMdPath}.tmp`;
|
const tempFile = `${claudeMdPath}.tmp`;
|
||||||
|
|
||||||
// Ensure directory exists
|
// Only write to folders that already exist - never create new directories
|
||||||
mkdirSync(folderPath, { recursive: true });
|
// 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
|
// Read existing content if file exists
|
||||||
let existingContent = '';
|
let existingContent = '';
|
||||||
@@ -320,6 +325,18 @@ export async function updateFolderClaudeMdFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||||
|
|
||||||
|
// 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*');
|
||||||
|
const fileExists = existsSync(claudeMdPath);
|
||||||
|
|
||||||
|
if (hasNoActivity && !fileExists) {
|
||||||
|
logger.debug('FOLDER_INDEX', 'Skipping empty CLAUDE.md creation', { folderPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
writeClaudeMdToFolder(folderPath, formatted);
|
writeClaudeMdToFolder(folderPath, formatted);
|
||||||
|
|
||||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||||
|
|||||||
@@ -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', () => {
|
describe('writeClaudeMdToFolder', () => {
|
||||||
it('should create CLAUDE.md in new folder', () => {
|
it('should skip non-existent folders (fix for spurious directory creation)', () => {
|
||||||
const folderPath = join(tempDir, 'new-folder');
|
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';
|
const content = '# Recent Activity\n\nTest content';
|
||||||
|
|
||||||
writeClaudeMdToFolder(folderPath, content);
|
writeClaudeMdToFolder(folderPath, content);
|
||||||
@@ -180,20 +194,22 @@ describe('writeClaudeMdToFolder', () => {
|
|||||||
expect(fileContent).not.toContain('Old content');
|
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 folderPath = join(tempDir, 'deep', 'nested', 'folder');
|
||||||
const content = 'Nested content';
|
const content = 'Nested content';
|
||||||
|
|
||||||
|
// Should not throw, should silently skip
|
||||||
writeClaudeMdToFolder(folderPath, content);
|
writeClaudeMdToFolder(folderPath, content);
|
||||||
|
|
||||||
|
// Nested directories should NOT be created
|
||||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||||
expect(existsSync(claudeMdPath)).toBe(true);
|
expect(existsSync(claudeMdPath)).toBe(false);
|
||||||
expect(existsSync(join(tempDir, 'deep'))).toBe(true);
|
expect(existsSync(join(tempDir, 'deep'))).toBe(false);
|
||||||
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not leave .tmp file after write (atomic write)', () => {
|
it('should not leave .tmp file after write (atomic write)', () => {
|
||||||
const folderPath = join(tempDir, 'atomic-test');
|
const folderPath = join(tempDir, 'atomic-test');
|
||||||
|
mkdirSync(folderPath, { recursive: true });
|
||||||
const content = 'Atomic write test';
|
const content = 'Atomic write test';
|
||||||
|
|
||||||
writeClaudeMdToFolder(folderPath, content);
|
writeClaudeMdToFolder(folderPath, content);
|
||||||
@@ -218,6 +234,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
|||||||
|
|
||||||
it('should fetch timeline and write CLAUDE.md', async () => {
|
it('should fetch timeline and write CLAUDE.md', async () => {
|
||||||
const folderPath = join(tempDir, 'api-test');
|
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 filePath = join(folderPath, 'test.ts');
|
||||||
|
|
||||||
const apiResponse = {
|
const apiResponse = {
|
||||||
@@ -412,6 +429,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
|||||||
|
|
||||||
it('should write CLAUDE.md to resolved projectRoot path', async () => {
|
it('should write CLAUDE.md to resolved projectRoot path', async () => {
|
||||||
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
|
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
|
||||||
|
mkdirSync(subfolderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||||
|
|
||||||
const apiResponse = {
|
const apiResponse = {
|
||||||
content: [{
|
content: [{
|
||||||
|
|||||||
Reference in New Issue
Block a user