Skip to content

Commit 9353d95

Browse files
committed
feat: add ghost project cleanup and harden test hub isolation
- Add deleteProject() with cascading deletion to MemoryHubDatabase - Add cleanupGhostProjects() with tmp/all modes for bulk ghost removal - Add hub:cleanup-ghost CLI command with --dry-run, --mode, --json options - Exclude ghost projects from memory health analysis by default - Harden 8 test files with setSharedHubForTests/CONTEXTATLAS_BASE_DIR isolation - Add tests/helpers/isolatedHub.ts shared test utility - Fix hub:stats revealing 2484 test artifacts in production hub (now 0 leak)
1 parent 7fe73bb commit 9353d95

13 files changed

Lines changed: 395 additions & 33 deletions

src/cli/commands/hubProjects.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs';
12
import { writeJson } from '../helpers.js';
23
import path from 'node:path';
34
import type { CommandRegistrar } from '../types.js';
@@ -69,6 +70,73 @@ export function registerHubProjectCommands(cli: CommandRegistrar): void {
6970
logger.info(` 分类统计:${JSON.stringify(stats.byCategory)}`);
7071
});
7172

73+
cli
74+
.command('hub:cleanup-ghost', '清理幽灵项目(路径不存在的项目及其记忆)')
75+
.option('--mode <mode>', '清理模式:tmp(仅 /tmp 路径)或 all(所有不存在路径)', { default: 'tmp' })
76+
.option('--dry-run', '仅预览,不执行删除')
77+
.option('--json', '以 JSON 输出结果')
78+
.action(async (options: { mode?: string; dryRun?: boolean; json?: boolean }) => {
79+
const { MemoryHubDatabase } = await import('../../memory/MemoryHubDatabase.js');
80+
const mode = options.mode === 'all' ? 'all' : 'tmp';
81+
const db = new MemoryHubDatabase();
82+
83+
try {
84+
if (options.dryRun) {
85+
const projects = db.listProjects();
86+
const ghosts = projects.filter((p) => {
87+
if (mode === 'tmp') {
88+
return p.path.startsWith('/tmp/') || p.path.startsWith('/var/folders/');
89+
}
90+
return !fs.existsSync(p.path);
91+
});
92+
93+
const result = {
94+
mode: 'dry-run' as const,
95+
cleanupMode: mode,
96+
ghostCount: ghosts.length,
97+
ghosts: ghosts.map((g) => ({ id: g.id, name: g.name, path: g.path })),
98+
};
99+
db.close();
100+
101+
if (options.json) {
102+
writeJson(result);
103+
return;
104+
}
105+
106+
logger.info(`幽灵项目预览 (mode=${mode}):`);
107+
logger.info(` 待清理项目数:${ghosts.length}`);
108+
for (const g of ghosts.slice(0, 20)) {
109+
logger.info(` - ${g.name} (${g.id})`);
110+
logger.info(` 路径:${g.path}`);
111+
}
112+
if (ghosts.length > 20) {
113+
logger.info(` ... 及其他 ${ghosts.length - 20} 个`);
114+
}
115+
logger.info('');
116+
logger.info('执行清理: contextatlas hub:cleanup-ghost --mode ' + mode);
117+
return;
118+
}
119+
120+
const result = db.cleanupGhostProjects({ mode });
121+
db.close();
122+
123+
if (options.json) {
124+
writeJson({ mode: 'apply', cleanupMode: mode, ...result });
125+
return;
126+
}
127+
128+
logger.info(`幽灵项目清理完成 (mode=${mode}):`);
129+
logger.info(` 删除项目数:${result.projectsRemoved}`);
130+
logger.info(` 删除功能记忆数:${result.featureMemoriesRemoved}`);
131+
logger.info(` 删除长期记忆数:${result.longTermMemoriesRemoved}`);
132+
} catch (err) {
133+
const error = err as Error;
134+
db.close();
135+
logger.error(`清理失败: ${error.message}`);
136+
process.exit(1);
137+
}
138+
});
139+
72140
cli
73141
.command('hub:repair-project-identities', '修复历史项目 ID 到规范化路径派生 ID')
74142
.option('--dry-run', '仅输出将要执行的修复计划,不修改数据库')

src/memory/MemoryHubDatabase.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ export interface LongTermMemoryRow {
102102
updated_at: string;
103103
}
104104

105+
export interface DeleteProjectResult {
106+
projectId: string;
107+
featureMemories: number;
108+
memoryRelations: number;
109+
sharedIndexEntries: number;
110+
decisionRecords: number;
111+
longTermMemories: number;
112+
metaEntries: number;
113+
}
114+
115+
export interface CleanupGhostResult {
116+
projectsRemoved: number;
117+
featureMemoriesRemoved: number;
118+
longTermMemoriesRemoved: number;
119+
ghosts: Array<{
120+
projectId: string;
121+
name: string;
122+
path: string;
123+
featureMemories: number;
124+
longTermMemories: number;
125+
}>;
126+
}
127+
105128
export interface ProjectRepairReport {
106129
scannedProjects: number;
107130
repairedProjects: number;
@@ -644,6 +667,142 @@ export class MemoryHubDatabase {
644667
return report;
645668
}
646669

670+
// ===========================================
671+
// Project 删除与幽灵清理
672+
// ===========================================
673+
674+
/**
675+
* 级联删除项目及其所有关联数据
676+
*
677+
* 删除顺序(按 FK 依赖):
678+
* 1. memory_relations(引用 feature_memories)
679+
* 2. shared_index(引用 feature_memories)
680+
* 3. feature_memories(引用 projects)— FTS 触发器自动同步
681+
* 4. decision_records(引用 projects)
682+
* 5. long_term_memories(引用 projects)— FTS 触发器自动同步
683+
* 6. project_memory_meta(引用 projects)
684+
* 7. projects
685+
*/
686+
deleteProject(projectId: string): DeleteProjectResult {
687+
const result: DeleteProjectResult = {
688+
projectId,
689+
featureMemories: 0,
690+
memoryRelations: 0,
691+
sharedIndexEntries: 0,
692+
decisionRecords: 0,
693+
longTermMemories: 0,
694+
metaEntries: 0,
695+
};
696+
697+
const tx = this.db.transaction(() => {
698+
// 获取该项目的所有 feature memory IDs(用于清理 relations)
699+
const memoryIds = this.db
700+
.prepare('SELECT id FROM feature_memories WHERE project_id = ?')
701+
.all(projectId)
702+
.map((r: any) => r.id as number);
703+
704+
// 1. 删除引用该项目 feature memories 的 relations
705+
if (memoryIds.length > 0) {
706+
const placeholders = memoryIds.map(() => '?').join(',');
707+
const delRelations = this.db.prepare(
708+
`DELETE FROM memory_relations WHERE from_memory_id IN (${placeholders}) OR to_memory_id IN (${placeholders})`,
709+
);
710+
const relResult = delRelations.run(...memoryIds, ...memoryIds);
711+
result.memoryRelations = relResult.changes;
712+
}
713+
714+
// 2. 删除引用该项目 feature memories 的 shared_index
715+
if (memoryIds.length > 0) {
716+
const placeholders = memoryIds.map(() => '?').join(',');
717+
const delShared = this.db.prepare(
718+
`DELETE FROM shared_index WHERE memory_id IN (${placeholders})`,
719+
);
720+
const sharedResult = delShared.run(...memoryIds);
721+
result.sharedIndexEntries = sharedResult.changes;
722+
}
723+
724+
// 3. 删除 feature memories(FTS 触发器自动同步)
725+
const delFeatures = this.db.prepare('DELETE FROM feature_memories WHERE project_id = ?');
726+
result.featureMemories = delFeatures.run(projectId).changes;
727+
728+
// 4. 删除 decision records
729+
const delDecisions = this.db.prepare('DELETE FROM decision_records WHERE project_id = ?');
730+
result.decisionRecords = delDecisions.run(projectId).changes;
731+
732+
// 5. 删除 long term memories(FTS 触发器自动同步)
733+
const delLongTerm = this.db.prepare('DELETE FROM long_term_memories WHERE project_id = ?');
734+
result.longTermMemories = delLongTerm.run(projectId).changes;
735+
736+
// 6. 删除 project_memory_meta
737+
const delMeta = this.db.prepare('DELETE FROM project_memory_meta WHERE project_id = ?');
738+
result.metaEntries = delMeta.run(projectId).changes;
739+
740+
// 7. 删除 project 本身
741+
const delProject = this.db.prepare('DELETE FROM projects WHERE id = ?');
742+
delProject.run(projectId);
743+
});
744+
745+
tx();
746+
logger.info({ projectId, result }, '项目及关联数据已级联删除');
747+
return result;
748+
}
749+
750+
/**
751+
* 清理幽灵项目(路径不存在的项目)
752+
*
753+
* @param options.mode - 'all' 清理所有不存在路径的项目;'tmp' 仅清理 /tmp 路径项目
754+
*/
755+
cleanupGhostProjects(options: { mode: 'all' | 'tmp' } = { mode: 'tmp' }): CleanupGhostResult {
756+
const projects = this.listProjects();
757+
const ghostProjects: Array<{ id: string; name: string; path: string }> = [];
758+
759+
for (const project of projects) {
760+
const isTmp = project.path.startsWith('/tmp/') || project.path.startsWith('/var/folders/');
761+
const pathExists = fs.existsSync(project.path);
762+
763+
if (options.mode === 'tmp') {
764+
if (isTmp) {
765+
ghostProjects.push({ id: project.id, name: project.name, path: project.path });
766+
}
767+
} else {
768+
if (!pathExists) {
769+
ghostProjects.push({ id: project.id, name: project.name, path: project.path });
770+
}
771+
}
772+
}
773+
774+
const totalDeleted: CleanupGhostResult = {
775+
projectsRemoved: 0,
776+
featureMemoriesRemoved: 0,
777+
longTermMemoriesRemoved: 0,
778+
ghosts: [],
779+
};
780+
781+
for (const ghost of ghostProjects) {
782+
const result = this.deleteProject(ghost.id);
783+
totalDeleted.projectsRemoved++;
784+
totalDeleted.featureMemoriesRemoved += result.featureMemories;
785+
totalDeleted.longTermMemoriesRemoved += result.longTermMemories;
786+
totalDeleted.ghosts.push({
787+
projectId: ghost.id,
788+
name: ghost.name,
789+
path: ghost.path,
790+
featureMemories: result.featureMemories,
791+
longTermMemories: result.longTermMemories,
792+
});
793+
}
794+
795+
logger.info(
796+
{
797+
mode: options.mode,
798+
removed: totalDeleted.projectsRemoved,
799+
featuresRemoved: totalDeleted.featureMemoriesRemoved,
800+
},
801+
'幽灵项目清理完成',
802+
);
803+
return totalDeleted;
804+
}
805+
647806
// ===========================================
648807
// Project Meta 管理
649808
// ===========================================

src/monitoring/memoryHealth.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,17 @@ export interface ProjectMemoryScore {
5959
issues: string[];
6060
}
6161

62+
export interface GhostProjectSummary {
63+
totalGhostProjects: number;
64+
excludedFromAnalysis: number;
65+
}
66+
6267
export interface MemoryHealthReport {
6368
longTermFreshness: LongTermMemoryFreshness;
6469
featureMemoryHealth: FeatureMemoryHealth;
6570
catalogConsistency: CatalogConsistency;
6671
projectScores: ProjectMemoryScore[];
72+
ghostProjects?: GhostProjectSummary;
6773
overall: {
6874
status: 'healthy' | 'degraded' | 'unhealthy';
6975
issues: string[];
@@ -275,6 +281,13 @@ function buildOverallAssessment(report: MemoryHealthReport): void {
275281
}
276282
}
277283

284+
// Ghost project recommendation
285+
if (report.ghostProjects && report.ghostProjects.totalGhostProjects > 0) {
286+
recommendations.push(
287+
`清理 ${report.ghostProjects.totalGhostProjects} 个幽灵项目: contextatlas hub:cleanup-ghost${report.ghostProjects.totalGhostProjects > 10 ? ' --mode tmp' : ' --mode all'}`,
288+
);
289+
}
290+
278291
const status: 'healthy' | 'degraded' | 'unhealthy' =
279292
issues.length === 0
280293
? 'healthy'
@@ -290,16 +303,27 @@ function buildOverallAssessment(report: MemoryHealthReport): void {
290303
// ===========================================
291304

292305
export async function analyzeMemoryHealth(
293-
options: { projectRoots?: string[]; staleDays?: number } = {},
306+
options: { projectRoots?: string[]; staleDays?: number; excludeGhostProjects?: boolean } = {},
294307
): Promise<MemoryHealthReport> {
295308
const hub = new MemoryHubDatabase();
296309
try {
297310
const allowedProjectRoots = options.projectRoots?.length
298311
? new Set(options.projectRoots.map((projectRoot) => path.resolve(projectRoot)))
299312
: null;
313+
const excludeGhosts = options.excludeGhostProjects !== false; // default true
300314
// 1. Long-term memory freshness (across all projects)
301315
let allLongTermItems: ResolvedLongTermMemoryItem[] = [];
302-
const projects = hub.listProjects().filter((project) => {
316+
let allProjects = hub.listProjects();
317+
318+
// Ghost project filtering
319+
let ghostProjectCount = 0;
320+
if (excludeGhosts) {
321+
const before = allProjects.length;
322+
allProjects = allProjects.filter((project) => fs.existsSync(project.path));
323+
ghostProjectCount = before - allProjects.length;
324+
}
325+
326+
const projects = allProjects.filter((project) => {
303327
if (!allowedProjectRoots) {
304328
return true;
305329
}
@@ -417,6 +441,9 @@ export async function analyzeMemoryHealth(
417441
featureMemoryHealth,
418442
catalogConsistency,
419443
projectScores,
444+
ghostProjects: ghostProjectCount > 0
445+
? { totalGhostProjects: ghostProjectCount, excludedFromAnalysis: ghostProjectCount }
446+
: undefined,
420447
overall: { status: 'healthy', issues: [], recommendations: [] },
421448
};
422449

@@ -476,6 +503,14 @@ export function formatMemoryHealthReport(report: MemoryHealthReport): string {
476503
}
477504
lines.push('');
478505

506+
// Ghost projects
507+
if (report.ghostProjects && report.ghostProjects.totalGhostProjects > 0) {
508+
lines.push('Ghost Projects:');
509+
lines.push(` Excluded from analysis: ${report.ghostProjects.excludedFromAnalysis}`);
510+
lines.push(` Hint: run 'contextatlas hub:cleanup-ghost' to remove`);
511+
lines.push('');
512+
}
513+
479514
// Project scores
480515
if (report.projectScores.length > 0) {
481516
lines.push('Project Scores:');

tests/application-executeAssembleContext.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'node:path';
55
import test from 'node:test';
66
import { executeAssembleContext } from '../src/application/memory/executeAssembleContext.js';
77
import { executeCreateCheckpoint } from '../src/application/memory/executeCheckpoints.js';
8+
import { MemoryHubDatabase } from '../src/memory/MemoryHubDatabase.js';
89
import { MemoryStore } from '../src/memory/MemoryStore.js';
910

1011
async function withTempProject(
@@ -13,10 +14,13 @@ async function withTempProject(
1314
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cw-assemble-context-'));
1415
const projectRoot = path.join(tempDir, 'project');
1516
mkdirSync(projectRoot, { recursive: true });
16-
17+
const hub = new MemoryHubDatabase(path.join(tempDir, 'memory-hub.db'));
18+
MemoryStore.setSharedHubForTests(hub);
1719
try {
1820
await run(projectRoot);
1921
} finally {
22+
MemoryStore.resetSharedHubForTests();
23+
hub.close();
2024
rmSync(tempDir, { recursive: true, force: true });
2125
}
2226
}

tests/application-executeMemoryHub.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,17 @@ async function withTempHub(
1818
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cw-memory-hub-'));
1919
const projectRoot = path.join(tempDir, 'project');
2020
const dbPath = path.join(tempDir, 'memory-hub.db');
21+
const previousBaseDir = process.env.CONTEXTATLAS_BASE_DIR;
22+
process.env.CONTEXTATLAS_BASE_DIR = tempDir;
2123

2224
try {
2325
await run(projectRoot, dbPath);
2426
} finally {
27+
if (previousBaseDir === undefined) {
28+
delete process.env.CONTEXTATLAS_BASE_DIR;
29+
} else {
30+
process.env.CONTEXTATLAS_BASE_DIR = previousBaseDir;
31+
}
2532
MemoryStore.resetSharedHubForTests();
2633
rmSync(tempDir, { recursive: true, force: true });
2734
}
@@ -297,8 +304,7 @@ test('executeManageProjects returns message when no projects registered', async
297304
format: 'text',
298305
});
299306

300-
// Just check that we get a response about projects (may contain accumulated projects)
301-
assert.match(response.content[0].text, /Registered Projects/);
307+
assert.match(response.content[0].text, /No projects registered|Registered Projects/);
302308
});
303309
});
304310

0 commit comments

Comments
 (0)