fix: concrete standalone bugs — project name, dot-path, path-match, CLAUDE.md denylist

- #2663: derive project name from git repo root (rev-parse --show-toplevel), stable
  across subdirs/worktrees; fall back to basename(cwd) outside a repo.
- #2401: 'include last message' encodes cwd with both '/' and '.' -> '-' to match Claude
  Code's transcript dir naming, so a '.' in a path component no longer no-ops.
- #2691: PreToolUse:Read queries observations by BOTH absolute + cwd-relative path
  (value IN candidates) so context injection matches PostToolUse storage.
- #2400: CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST suppresses empty/skeleton CLAUDE.md
  injection in deny-listed folders.
- #2473: confirmed host-side (resolved upstream); added invariant test that our MCP
  server/tool names stay colon/dot-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-28 18:41:06 -07:00
parent 4016a8c43a
commit daf6d9dc0f
19 changed files with 681 additions and 196 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15 -1
View File
@@ -206,7 +206,21 @@ async function buildFileContextTimeline(input: NormalizedHookInput, filePath: st
const cwd = input.cwd || process.cwd();
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
const queryParams = new URLSearchParams({ path: relativePath });
// #2691 — PostToolUse stores whatever path form the observer recorded
// (absolute tool-input path, or project-root-relative per the prompt). The
// PreToolUse:Read query previously sent ONLY the cwd-relative form, so it
// never matched absolute-path storage. Send both candidate forms (forward-
// slashed, de-duped) as repeated `path` params so the key matches across
// both events regardless of how the path was stored.
const candidateQueryPaths = Array.from(new Set([
absolutePath.split(path.sep).join("/"),
relativePath,
].filter(Boolean)));
const queryParams = new URLSearchParams();
for (const candidate of candidateQueryPaths) {
queryParams.append('path', candidate);
}
if (context.allProjects.length > 0) {
queryParams.set('projects', context.allProjects.join(','));
}
+7 -2
View File
@@ -174,8 +174,13 @@ export function querySummariesMulti(
`).all(...projects, ...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
}
function cwdToDashed(cwd: string): string {
return cwd.replace(/\//g, '-');
export function cwdToDashed(cwd: string): string {
// Claude Code encodes a project's transcript directory by replacing BOTH path
// separators AND dots with dashes (e.g. `/Users/john.doe/proj` ->
// `-Users-john-doe-proj`). Replacing only `/` left a literal `.` in the dir
// name, so "Include last message" silently no-opped for any cwd component
// containing a dot — Unix usernames like `john.doe`, dotted dirs, etc. (#2401).
return cwd.replace(/[/.]/g, '-');
}
function parseAssistantTextFromLine(line: string): string | null {
+1
View File
@@ -9,4 +9,5 @@ export {
querySummaries,
buildTimeline,
getPriorSessionMessages,
cwdToDashed,
} from './ObservationCompiler.js';
+19 -4
View File
@@ -96,14 +96,29 @@ export function getObservationsForSession(
export function getObservationsByFilePath(
db: Database,
filePath: string,
filePath: string | string[],
options?: { projects?: string[]; limit?: number }
): ObservationRecord[] {
const rawLimit = options?.limit;
const limit = Number.isInteger(rawLimit) && (rawLimit as number) > 0
? Math.min(rawLimit as number, 100)
: 15;
const params: (string | number)[] = [filePath, filePath];
// #2691 — PreToolUse:Read and PostToolUse can disagree on the stored path
// form (absolute vs project-root-relative vs cwd-relative). Accept multiple
// candidate path forms and match observations whose files_read/files_modified
// contain ANY of them, so context injection keyed on path is consistent
// across the two events. De-duplicate to keep the IN() clause minimal.
const candidatePaths = Array.from(
new Set((Array.isArray(filePath) ? filePath : [filePath]).filter(p => typeof p === 'string' && p.length > 0))
);
if (candidatePaths.length === 0) {
return [];
}
const pathPlaceholders = candidatePaths.map(() => '?').join(',');
// Params order mirrors the two json_each subqueries (files_read, then files_modified).
const params: (string | number)[] = [...candidatePaths, ...candidatePaths];
let projectClause = '';
if (options?.projects?.length) {
@@ -118,8 +133,8 @@ export function getObservationsByFilePath(
SELECT *
FROM observations
WHERE (
(files_read LIKE '[%' AND EXISTS (SELECT 1 FROM json_each(files_read) WHERE value = ?))
OR (files_modified LIKE '[%' AND EXISTS (SELECT 1 FROM json_each(files_modified) WHERE value = ?))
(files_read LIKE '[%' AND EXISTS (SELECT 1 FROM json_each(files_read) WHERE value IN (${pathPlaceholders})))
OR (files_modified LIKE '[%' AND EXISTS (SELECT 1 FROM json_each(files_modified) WHERE value IN (${pathPlaceholders})))
)
${projectClause}
ORDER BY created_at_epoch DESC
@@ -142,8 +142,14 @@ export class DataRoutes extends BaseRouteHandler {
});
private handleGetObservationsByFile = this.wrapHandler((req: Request, res: Response): void => {
const filePath = req.query.path as string | undefined;
if (!filePath) {
// #2691 — `path` may be repeated (?path=abs&path=rel) to carry multiple
// candidate forms (absolute, project-root-relative, cwd-relative) so the
// query matches however PostToolUse stored the path. Paths can contain
// commas, so we rely on repeated query params rather than comma-splitting.
const rawPath = req.query.path;
const candidatePaths = (Array.isArray(rawPath) ? rawPath : [rawPath])
.filter((p): p is string => typeof p === 'string' && p.length > 0);
if (candidatePaths.length === 0) {
this.badRequest(res, 'path query parameter is required');
return;
}
@@ -154,7 +160,7 @@ export class DataRoutes extends BaseRouteHandler {
const limit = Number.isFinite(parsedLimit) && parsedLimit! > 0 ? parsedLimit : undefined;
const db = this.dbManager.getSessionStore().db;
const observations = getObservationsByFilePath(db, filePath, { projects, limit });
const observations = getObservationsByFilePath(db, candidatePaths, { projects, limit });
res.json({ observations, count: observations.length });
});
+3 -1
View File
@@ -46,7 +46,8 @@ export interface SettingsDefaults {
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string;
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string;
CLAUDE_MEM_EXCLUDED_PROJECTS: string;
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string;
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string;
CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST: string;
CLAUDE_MEM_SEMANTIC_INJECT: string;
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string;
CLAUDE_MEM_TIER_ROUTING_ENABLED: string;
@@ -125,6 +126,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST: '[]', // #2400 — JSON array of glob patterns; when a folder matches AND its generated CLAUDE.md would be empty/skeleton, skip injection (avoids polluting non-content dirs with empty skeletons). Default [] preserves existing behavior.
CLAUDE_MEM_SEMANTIC_INJECT: 'false', // Inject relevant past observations on every UserPromptSubmit (experimental, disabled by default)
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: '5', // Top-N most relevant observations to inject per prompt
CLAUDE_MEM_TIER_ROUTING_ENABLED: 'true', // Route observations to models by complexity
+24
View File
@@ -6,6 +6,7 @@ import { formatDate, groupByDate } from '../shared/timeline-formatting.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import { workerHttpRequest } from '../shared/worker-utils.js';
import { paths } from '../shared/paths.js';
import { matchesAnyGlob } from './project-filter.js';
const SETTINGS_PATH = paths.settings();
@@ -240,6 +241,20 @@ export async function updateFolderClaudeMdFiles(
logger.warn('FOLDER_INDEX', 'Failed to parse CLAUDE_MEM_FOLDER_MD_EXCLUDE setting');
}
// #2400 — deny-list of glob patterns where an empty/skeleton CLAUDE.md must
// NOT be injected. Unlike CLAUDE_MEM_FOLDER_MD_EXCLUDE (which excludes the
// folder entirely), this only suppresses injection when the generated content
// is empty/skeleton; folders with real activity still get a CLAUDE.md.
let skeletonDenylistPatterns: string[] = [];
try {
const parsed = JSON.parse(settings.CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST || '[]');
if (Array.isArray(parsed)) {
skeletonDenylistPatterns = parsed.filter((p): p is string => typeof p === 'string');
}
} catch {
logger.warn('FOLDER_INDEX', 'Failed to parse CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST setting');
}
const foldersWithActiveClaudeMd = new Set<string>();
for (const filePath of filePaths) {
@@ -331,8 +346,17 @@ export async function updateFolderClaudeMdFiles(
const claudeMdPath = path.join(folderPath, targetFilename);
const hasNoActivity = formatted.includes('*No recent activity*');
const isEmptyOrSkeleton = formatted.trim() === '' || hasNoActivity;
const fileExists = existsSync(claudeMdPath);
// #2400 — when the generated content is empty/skeleton AND the folder
// matches the user's deny-list, never inject (skip even if the file exists,
// so we don't pollute non-content dirs with empty skeletons).
if (isEmptyOrSkeleton && matchesAnyGlob(folderPath, skeletonDenylistPatterns)) {
logger.debug('FOLDER_INDEX', 'Skipping skeleton CLAUDE.md in deny-listed folder', { folderPath, targetFilename });
continue;
}
if (hasNoActivity && !fileExists) {
logger.debug('FOLDER_INDEX', 'Skipping empty context file creation', { folderPath, targetFilename });
continue;
+26
View File
@@ -21,6 +21,32 @@ function globToRegex(pattern: string): RegExp {
return new RegExp(`^${regex}$`);
}
/**
* Returns true when `folderPath` matches any of the supplied glob patterns.
* Patterns support `*`, `**`, `?`, and a leading `~`. Matches against both the
* full normalized path and the basename. Reuses the same glob semantics as
* project exclusion. Used by the skeleton-CLAUDE.md deny-list (#2400).
*/
export function matchesAnyGlob(folderPath: string, patterns: string[]): boolean {
if (!patterns.length) return false;
const normalizedPath = folderPath.replace(/\\/g, '/');
const pathBasename = basename(normalizedPath);
for (const rawPattern of patterns) {
const pattern = rawPattern.trim();
if (!pattern) continue;
try {
const regex = globToRegex(pattern);
if (regex.test(normalizedPath) || regex.test(pathBasename)) {
return true;
}
} catch (error: unknown) {
logger.warn('PROJECT_NAME', 'Invalid glob pattern', { pattern, error: error instanceof Error ? error.message : String(error) });
continue;
}
}
return false;
}
export function isProjectExcluded(projectPath: string, exclusionPatterns: string): boolean {
if (!exclusionPatterns || !exclusionPatterns.trim()) {
return false;
+29 -1
View File
@@ -1,5 +1,6 @@
import { homedir } from 'os'
import path from 'path';
import { execFileSync } from 'child_process';
import { logger } from './logger.js';
import { detectWorktree } from './worktree.js';
@@ -10,6 +11,27 @@ function expandTilde(p: string): string {
return p
}
/**
* Resolve the git repository ROOT for a directory, so a project's name is
* stable across its subdirectories and worktrees (#2663). Returns the absolute
* repo-root path, or null when `dir` is not inside a git repo (or git is
* unavailable). `--show-toplevel` resolves to the working-tree root even when
* invoked from a worktree or a nested subdirectory.
*/
function findGitRepoRoot(dir: string): string | null {
try {
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: dir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return root || null;
} catch {
// Not a git repo, git not installed, or dir does not exist — fall back to basename.
return null;
}
}
export function getProjectName(cwd: string | null | undefined): string {
if (!cwd || cwd.trim() === '') {
logger.warn('PROJECT_NAME', 'Empty cwd provided, using fallback', { cwd });
@@ -18,7 +40,13 @@ export function getProjectName(cwd: string | null | undefined): string {
const expanded = expandTilde(cwd)
const basename = path.basename(expanded);
// #2663 — derive the project name from the git repo root when inside a repo so
// the name is stable across subdirectories/worktrees. Fall back to the cwd
// basename when not in a repo.
const repoRoot = findGitRepoRoot(expanded);
const nameSource = repoRoot ?? expanded;
const basename = path.basename(nameSource);
if (basename === '') {
const isWindows = process.platform === 'win32';
@@ -0,0 +1,68 @@
// #2401 — "Include last message" silently no-ops when a cwd component contains
// a ".". Claude Code encodes its per-project transcript directory by replacing
// BOTH path separators AND dots with dashes (e.g. /Users/john.doe/proj ->
// -Users-john-doe-proj). cwdToDashed used to replace only "/", leaving a literal
// "." in the directory name, so the transcript file was never found.
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
describe('cwdToDashed (#2401)', () => {
it('replaces both slashes and dots with dashes (matches Claude Code encoding)', async () => {
const { cwdToDashed } = await import('../../src/services/context/ObservationCompiler.js');
expect(cwdToDashed('/Users/john.doe/my-project')).toBe('-Users-john-doe-my-project');
});
it('still handles paths with no dots', async () => {
const { cwdToDashed } = await import('../../src/services/context/ObservationCompiler.js');
expect(cwdToDashed('/Users/jane/app')).toBe('-Users-jane-app');
});
it('encodes dotted directory components (e.g. version dirs)', async () => {
const { cwdToDashed } = await import('../../src/services/context/ObservationCompiler.js');
expect(cwdToDashed('/srv/app.v2.1/src')).toBe('-srv-app-v2-1-src');
});
});
describe('getPriorSessionMessages — dot in cwd component (#2401)', () => {
// Use the config dir the code actually resolves at runtime (paths.ts reads
// CLAUDE_CONFIG_DIR at module init, so we read the resolved value rather than
// trying to override the env after the fact). We write the transcript at the
// exact path getPriorSessionMessages will look up, then clean it up.
const cwd = '/Users/john.doe/some-project';
const dashedCwd = '-Users-john-doe-some-project'; // Claude Code: slashes AND dots -> dashes
const priorSessionId = 'prior-session-2401-abc';
let projectDir: string;
let transcriptPath: string;
beforeAll(async () => {
const { CLAUDE_CONFIG_DIR } = await import('../../src/shared/paths.js');
projectDir = join(CLAUDE_CONFIG_DIR, 'projects', dashedCwd);
transcriptPath = join(projectDir, `${priorSessionId}.jsonl`);
mkdirSync(projectDir, { recursive: true });
const transcriptLine = JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'The recovered prior assistant message.' }] },
});
writeFileSync(transcriptPath, transcriptLine + '\n');
});
afterAll(() => {
// Only remove the synthetic project dir we created; never the real config dir.
rmSync(projectDir, { recursive: true, force: true });
});
it('finds the transcript for a cwd whose component contains a dot', async () => {
const { getPriorSessionMessages } = await import('../../src/services/context/ObservationCompiler.js');
const observations = [
{ memory_session_id: priorSessionId } as any,
];
const config = { showLastMessage: true } as any;
const result = getPriorSessionMessages(observations, config, 'current-session-id', cwd);
expect(result.assistantMessage).toBe('The recovered prior assistant message.');
});
});
+25
View File
@@ -256,6 +256,31 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
expect(ctx).not.toContain('worker unavailable');
});
it('queries with BOTH absolute and cwd-relative path candidates (#2691)', async () => {
const future = Date.now() + 60_000;
let capturedUrl = '';
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((url: string | URL | Request) => {
capturedUrl = String(url);
return Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]));
});
await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
const parsed = new URL(capturedUrl);
const pathParams = parsed.searchParams.getAll('path');
// Both candidate forms are sent so the worker can match however the path was
// stored at PostToolUse time (absolute vs cwd-relative).
const absoluteForm = testFile.split(/[\\/]/).join('/');
expect(pathParams).toContain(absoluteForm);
expect(pathParams).toContain('test.md'); // cwd-relative form
expect(pathParams.length).toBeGreaterThanOrEqual(2);
});
it('skips directories before querying file history', async () => {
const directoryPath = join(tmpDir, 'large-dir');
mkdirSync(directoryPath);
@@ -0,0 +1,56 @@
// #2473 — Plugin MCP server tools were never surfaced to the assistant because
// Claude Code (host-side) built the fully-qualified name with colons
// (`plugin:claude-mem:mcp-search`), and the deferred-tool pattern
// `mcp__<server>__<tool>` rejected the colons. The root cause is HOST-SIDE and
// not fixable in our code (and current Claude Code namespaces with underscores:
// `mcp__plugin_claude-mem_mcp-search__*`, which register correctly).
//
// The one thing under OUR control is the server name we declare in
// plugin/.mcp.json and the tool names we register. Both must stay within the
// MCP-safe character set (alphanumeric, `_`, `-`) and contain NO `:` or `.`, so
// that we never contribute a colon/dot to the qualified name. These tests pin
// that invariant so a future rename can't silently reintroduce the breakage.
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
describe('MCP server name safety (#2473)', () => {
it('every server key declared in plugin/.mcp.json is colon/dot-free and MCP-safe', () => {
const mcpJsonPath = join(import.meta.dir, '..', '..', 'plugin', '.mcp.json');
const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
const serverNames = Object.keys(config.mcpServers ?? {});
expect(serverNames.length).toBeGreaterThan(0);
for (const name of serverNames) {
expect(name).not.toContain(':');
expect(name).not.toContain('.');
expect(name).toMatch(SAFE_NAME);
}
});
it('every registered MCP tool name is colon/dot-free and within the 64-char fully-qualified budget', () => {
// Read the source rather than importing it (importing runs the stdio server
// bootstrap, which is undesirable in a unit test). Extract `name: '...'`
// entries from the tools array.
const serverSrcPath = join(import.meta.dir, '..', '..', 'src', 'servers', 'mcp-server.ts');
const src = readFileSync(serverSrcPath, 'utf-8');
const toolNames = Array.from(src.matchAll(/^\s{4}name: '([^']+)',?$/gm)).map(m => m[1]);
expect(toolNames.length).toBeGreaterThan(5);
// Worst-case qualified prefix the host applies for this plugin's server.
const QUALIFIED_PREFIX = 'mcp__plugin_claude-mem_mcp-search__';
for (const tool of toolNames) {
expect(tool).not.toContain(':');
expect(tool).not.toContain('.');
expect(tool).toMatch(SAFE_NAME);
// Many MCP hosts cap tool names at 64 chars; staying within budget keeps
// the tool registrable everywhere.
expect((QUALIFIED_PREFIX + tool).length).toBeLessThanOrEqual(64);
}
});
});
@@ -0,0 +1,87 @@
// #2691 — Path inconsistency between PreToolUse:Read and PostToolUse broke
// context injection. PostToolUse stores whatever path form the observer
// recorded (often the absolute tool-input path), while PreToolUse:Read queried
// ONLY the cwd-relative form, so the exact-match lookup never matched.
// getObservationsByFilePath now accepts multiple candidate path forms and
// matches an observation whose files_read/files_modified contain ANY of them,
// yielding a consistent key across both handlers.
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
import { getObservationsByFilePath } from '../../../src/services/sqlite/observations/get.js';
describe('getObservationsByFilePath — multi-candidate path matching (#2691)', () => {
let store: SessionStore;
beforeEach(() => {
store = new SessionStore(':memory:');
});
afterEach(() => {
store.close();
});
function seedObservationWithReadPath(readPath: string, sessionSuffix: string): number {
const sdkId = store.createSDKSession(`content-${sessionSuffix}`, 'proj', 'prompt');
store.updateMemorySessionId(sdkId, `session-${sessionSuffix}`);
const result = store.storeObservations(
`session-${sessionSuffix}`,
'proj',
[{
type: 'discovery',
title: `touched ${readPath}`,
subtitle: null,
facts: ['fact'],
narrative: null,
concepts: [],
files_read: [readPath],
files_modified: [],
}],
null,
0,
0,
1_700_000_000_000,
);
return result.observationIds[0];
}
it('matches an observation stored under an ABSOLUTE path when querying multiple candidate forms', () => {
const absolutePath = '/Users/dev/proj/src/services/foo.ts';
const relativePath = 'src/services/foo.ts';
const id = seedObservationWithReadPath(absolutePath, 'abs');
// PreToolUse:Read sends both the absolute and the relative candidate forms.
const matches = getObservationsByFilePath(store.db, [absolutePath, relativePath]);
expect(matches.map(o => o.id)).toContain(id);
});
it('matches an observation stored under a RELATIVE path when querying multiple candidate forms', () => {
const absolutePath = '/Users/dev/proj/src/services/bar.ts';
const relativePath = 'src/services/bar.ts';
const id = seedObservationWithReadPath(relativePath, 'rel');
const matches = getObservationsByFilePath(store.db, [absolutePath, relativePath]);
expect(matches.map(o => o.id)).toContain(id);
});
it('regression: the OLD single relative-path query would NOT match absolute storage', () => {
const absolutePath = '/Users/dev/proj/src/services/baz.ts';
const relativePath = 'src/services/baz.ts';
const id = seedObservationWithReadPath(absolutePath, 'old');
// Old behavior (single relative path) — no match. Demonstrates the bug.
const relativeOnly = getObservationsByFilePath(store.db, relativePath);
expect(relativeOnly.map(o => o.id)).not.toContain(id);
// New behavior (both forms) — match.
const both = getObservationsByFilePath(store.db, [absolutePath, relativePath]);
expect(both.map(o => o.id)).toContain(id);
});
it('backward compatible: a single string path still works', () => {
const absolutePath = '/Users/dev/proj/src/single.ts';
const id = seedObservationWithReadPath(absolutePath, 'single');
const matches = getObservationsByFilePath(store.db, absolutePath);
expect(matches.map(o => o.id)).toContain(id);
});
});
+88
View File
@@ -1072,3 +1072,91 @@ describe('CLAUDE.local.md support', () => {
expect(callUrl).not.toContain(encodeURIComponent('/project/src/b'));
});
});
describe('skeleton CLAUDE.md deny-list (#2400)', () => {
const ENV_KEY = 'CLAUDE_MEM_FOLDER_MD_SKELETON_DENYLIST';
let savedEnv: string | undefined;
beforeEach(() => {
savedEnv = process.env[ENV_KEY];
});
afterEach(() => {
if (savedEnv === undefined) {
delete process.env[ENV_KEY];
} else {
process.env[ENV_KEY] = savedEnv;
}
});
// API text with no parseable observation rows -> formatTimelineForClaudeMd
// returns '' (empty/skeleton).
const emptySkeletonResponse = {
content: [{ text: 'no observation rows here' }],
};
it('does NOT overwrite an existing CLAUDE.md with a skeleton when the folder matches the deny-list', async () => {
process.env[ENV_KEY] = JSON.stringify(['**/transient']);
const folderPath = join(tempDir, 'transient');
mkdirSync(folderPath, { recursive: true });
const claudeMdPath = join(folderPath, 'CLAUDE.md');
const userContent = 'USER CONTENT — must be preserved';
writeFileSync(claudeMdPath, userContent);
const filePath = join(folderPath, 'file.ts');
global.fetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(emptySkeletonResponse),
} as Response));
await updateFolderClaudeMdFiles([filePath], 'test-project', 37777, tempDir);
// Deny-listed + empty/skeleton => injection suppressed, file untouched.
expect(readFileSync(claudeMdPath, 'utf-8')).toBe(userContent);
});
it('still injects when the folder does NOT match the deny-list (default behavior unchanged)', async () => {
process.env[ENV_KEY] = JSON.stringify(['**/some-other-dir']);
const folderPath = join(tempDir, 'content-dir');
mkdirSync(folderPath, { recursive: true });
const filePath = join(folderPath, 'file.ts');
global.fetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({
content: [{ text: '| #123 | 4:30 PM | 🔵 | Real observation | ~100 |' }],
}),
} as Response));
await updateFolderClaudeMdFiles([filePath], 'test-project', 37777, tempDir);
const claudeMdPath = join(folderPath, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(true);
expect(readFileSync(claudeMdPath, 'utf-8')).toContain('#123');
});
it('default (unset deny-list) preserves prior behavior — existing file gets the empty section rewritten', async () => {
delete process.env[ENV_KEY];
const folderPath = join(tempDir, 'no-denylist');
mkdirSync(folderPath, { recursive: true });
const claudeMdPath = join(folderPath, 'CLAUDE.md');
writeFileSync(claudeMdPath, 'PRE-EXISTING');
const filePath = join(folderPath, 'file.ts');
global.fetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(emptySkeletonResponse),
} as Response));
await updateFolderClaudeMdFiles([filePath], 'test-project', 37777, tempDir);
// With no deny-list, the existing file is still processed (the new guard is
// a no-op), so the tagged context section is appended to the existing file.
const content = readFileSync(claudeMdPath, 'utf-8');
expect(content).toContain('PRE-EXISTING');
expect(content).toContain('<claude-mem-context>');
});
});
+40
View File
@@ -54,6 +54,46 @@ describe('getProjectName', () => {
});
});
describe('#2663 — name derived from git repo root', () => {
let tmp: string;
let repoRoot: string;
let nestedDir: string;
beforeAll(async () => {
const { mkdtempSync, mkdirSync, realpathSync } = await import('fs');
const { execFileSync } = await import('child_process');
const { join } = await import('path');
const { tmpdir } = await import('os');
// macOS /tmp symlinks to /private/tmp; realpath so `git --show-toplevel`
// (which returns the canonical path) matches our expectations.
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'cm-reporoot-')));
repoRoot = join(tmp, 'my-real-repo');
nestedDir = join(repoRoot, 'packages', 'deeply', 'nested');
mkdirSync(nestedDir, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: repoRoot });
});
afterAll(async () => {
const { rmSync } = await import('fs');
rmSync(tmp, { recursive: true, force: true });
});
it('deep subdirectory inside a repo yields the repo-root name', () => {
expect(getProjectName(nestedDir)).toBe('my-real-repo');
});
it('repo root itself yields the repo-root name', () => {
expect(getProjectName(repoRoot)).toBe('my-real-repo');
});
it('non-repo path falls back to basename(cwd)', () => {
// A path that does not exist (and therefore cannot be in a repo) must
// fall back to basename(cwd) rather than throwing or returning a root.
expect(getProjectName('/no/such/dir/standalone-folder')).toBe('standalone-folder');
});
});
describe('realistic scenarios from #1478', () => {
it('handles ~ the same as full home path', () => {
const home = homedir();