@massu/core 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +23 -35
- package/dist/hooks/post-edit-context.js +2 -2
- package/dist/hooks/post-tool-use.js +43 -58
- package/dist/hooks/pre-compact.js +23 -38
- package/dist/hooks/pre-delete-check.js +18 -31
- package/dist/hooks/quality-event.js +23 -35
- package/dist/hooks/session-end.js +62 -78
- package/dist/hooks/session-start.js +33 -42
- package/dist/hooks/user-prompt.js +23 -38
- package/package.json +8 -14
- package/src/adr-generator.ts +9 -2
- package/src/analytics.ts +9 -3
- package/src/audit-trail.ts +10 -3
- package/src/cloud-sync.ts +14 -18
- package/src/commands/init.ts +1 -5
- package/src/cost-tracker.ts +11 -6
- package/src/dependency-scorer.ts +9 -2
- package/src/docs-tools.ts +13 -10
- package/src/hooks/post-edit-context.ts +3 -3
- package/src/hooks/session-end.ts +3 -3
- package/src/hooks/session-start.ts +2 -2
- package/src/memory-db.ts +1351 -23
- package/src/memory-tools.ts +14 -15
- package/src/observability-tools.ts +13 -2
- package/src/prompt-analyzer.ts +9 -2
- package/src/regression-detector.ts +9 -3
- package/src/security-scorer.ts +9 -2
- package/src/sentinel-db.ts +43 -88
- package/src/sentinel-tools.ts +8 -11
- package/src/server.ts +1 -2
- package/src/team-knowledge.ts +9 -2
- package/src/tools.ts +771 -35
- package/src/validate-features-runner.ts +0 -1
- package/src/validation-engine.ts +9 -2
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
- package/src/__tests__/adr-generator.test.ts +0 -260
- package/src/__tests__/analytics.test.ts +0 -282
- package/src/__tests__/audit-trail.test.ts +0 -382
- package/src/__tests__/backfill-sessions.test.ts +0 -690
- package/src/__tests__/cli.test.ts +0 -290
- package/src/__tests__/cloud-sync.test.ts +0 -261
- package/src/__tests__/config-sections.test.ts +0 -359
- package/src/__tests__/config.test.ts +0 -732
- package/src/__tests__/cost-tracker.test.ts +0 -348
- package/src/__tests__/db.test.ts +0 -177
- package/src/__tests__/dependency-scorer.test.ts +0 -325
- package/src/__tests__/docs-integration.test.ts +0 -178
- package/src/__tests__/docs-tools.test.ts +0 -199
- package/src/__tests__/domains.test.ts +0 -236
- package/src/__tests__/hooks.test.ts +0 -221
- package/src/__tests__/import-resolver.test.ts +0 -95
- package/src/__tests__/integration/path-traversal.test.ts +0 -134
- package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
- package/src/__tests__/integration/tool-registration.test.ts +0 -146
- package/src/__tests__/memory-db.test.ts +0 -404
- package/src/__tests__/memory-enhancements.test.ts +0 -316
- package/src/__tests__/memory-tools.test.ts +0 -199
- package/src/__tests__/middleware-tree.test.ts +0 -177
- package/src/__tests__/observability-tools.test.ts +0 -595
- package/src/__tests__/observability.test.ts +0 -437
- package/src/__tests__/observation-extractor.test.ts +0 -167
- package/src/__tests__/page-deps.test.ts +0 -60
- package/src/__tests__/prompt-analyzer.test.ts +0 -298
- package/src/__tests__/regression-detector.test.ts +0 -295
- package/src/__tests__/rules.test.ts +0 -87
- package/src/__tests__/schema-mapper.test.ts +0 -29
- package/src/__tests__/security-scorer.test.ts +0 -238
- package/src/__tests__/security-utils.test.ts +0 -175
- package/src/__tests__/sentinel-db.test.ts +0 -491
- package/src/__tests__/sentinel-scanner.test.ts +0 -750
- package/src/__tests__/sentinel-tools.test.ts +0 -324
- package/src/__tests__/sentinel-types.test.ts +0 -750
- package/src/__tests__/server.test.ts +0 -452
- package/src/__tests__/session-archiver.test.ts +0 -524
- package/src/__tests__/session-state-generator.test.ts +0 -900
- package/src/__tests__/team-knowledge.test.ts +0 -327
- package/src/__tests__/tools.test.ts +0 -340
- package/src/__tests__/transcript-parser.test.ts +0 -195
- package/src/__tests__/trpc-index.test.ts +0 -25
- package/src/__tests__/validate-features-runner.test.ts +0 -517
- package/src/__tests__/validation-engine.test.ts +0 -300
- package/src/core-tools.ts +0 -685
- package/src/memory-queries.ts +0 -804
- package/src/memory-schema.ts +0 -546
- package/src/tool-helpers.ts +0 -41
|
@@ -1,690 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
-
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
-
|
|
4
|
-
// ============================================================
|
|
5
|
-
// backfill-sessions.ts tests
|
|
6
|
-
// The module is a standalone script (no exports), so we test
|
|
7
|
-
// its internal logic by mocking its dependencies and verifying
|
|
8
|
-
// observable side-effects through those mocks.
|
|
9
|
-
// ============================================================
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
|
|
13
|
-
// ------------------------------------
|
|
14
|
-
// Mock all external dependencies
|
|
15
|
-
// ------------------------------------
|
|
16
|
-
|
|
17
|
-
vi.mock('fs', async (importOriginal) => {
|
|
18
|
-
const actual = await importOriginal<typeof import('fs')>();
|
|
19
|
-
return {
|
|
20
|
-
...actual,
|
|
21
|
-
readdirSync: vi.fn(),
|
|
22
|
-
statSync: vi.fn(),
|
|
23
|
-
existsSync: vi.fn(),
|
|
24
|
-
};
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
vi.mock('../memory-db.ts', () => ({
|
|
28
|
-
getMemoryDb: vi.fn(),
|
|
29
|
-
createSession: vi.fn(),
|
|
30
|
-
addObservation: vi.fn(),
|
|
31
|
-
addSummary: vi.fn(),
|
|
32
|
-
addUserPrompt: vi.fn(),
|
|
33
|
-
deduplicateFailedAttempt: vi.fn(),
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
|
-
vi.mock('../transcript-parser.ts', () => ({
|
|
37
|
-
parseTranscript: vi.fn(),
|
|
38
|
-
extractUserMessages: vi.fn(),
|
|
39
|
-
getLastAssistantMessage: vi.fn(),
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
|
-
vi.mock('../observation-extractor.ts', () => ({
|
|
43
|
-
extractObservationsFromEntries: vi.fn(),
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
vi.mock('../config.ts', () => ({
|
|
47
|
-
getProjectRoot: vi.fn(() => '/home/user/my-project'),
|
|
48
|
-
getConfig: vi.fn(() => ({
|
|
49
|
-
toolPrefix: 'massu',
|
|
50
|
-
project: { name: 'my-project', root: '/home/user/my-project' },
|
|
51
|
-
})),
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
|
-
// Import mocks after vi.mock declarations
|
|
55
|
-
import { readdirSync, statSync, existsSync } from 'fs';
|
|
56
|
-
import {
|
|
57
|
-
getMemoryDb,
|
|
58
|
-
createSession,
|
|
59
|
-
addObservation,
|
|
60
|
-
addSummary,
|
|
61
|
-
addUserPrompt,
|
|
62
|
-
deduplicateFailedAttempt,
|
|
63
|
-
} from '../memory-db.ts';
|
|
64
|
-
import { parseTranscript, extractUserMessages } from '../transcript-parser.ts';
|
|
65
|
-
import { extractObservationsFromEntries } from '../observation-extractor.ts';
|
|
66
|
-
import { getProjectRoot } from '../config.ts';
|
|
67
|
-
|
|
68
|
-
// ------------------------------------
|
|
69
|
-
// Typed mock references
|
|
70
|
-
// ------------------------------------
|
|
71
|
-
|
|
72
|
-
const mockReaddirSync = vi.mocked(readdirSync);
|
|
73
|
-
const mockStatSync = vi.mocked(statSync);
|
|
74
|
-
const mockExistsSync = vi.mocked(existsSync);
|
|
75
|
-
const mockGetMemoryDb = vi.mocked(getMemoryDb);
|
|
76
|
-
const mockCreateSession = vi.mocked(createSession);
|
|
77
|
-
const mockAddObservation = vi.mocked(addObservation);
|
|
78
|
-
const mockAddSummary = vi.mocked(addSummary);
|
|
79
|
-
const mockAddUserPrompt = vi.mocked(addUserPrompt);
|
|
80
|
-
const mockDeduplicateFailedAttempt = vi.mocked(deduplicateFailedAttempt);
|
|
81
|
-
const mockParseTranscript = vi.mocked(parseTranscript);
|
|
82
|
-
const mockExtractUserMessages = vi.mocked(extractUserMessages);
|
|
83
|
-
const mockExtractObservationsFromEntries = vi.mocked(extractObservationsFromEntries);
|
|
84
|
-
|
|
85
|
-
// ------------------------------------
|
|
86
|
-
// Shared mock DB object
|
|
87
|
-
// ------------------------------------
|
|
88
|
-
|
|
89
|
-
function makeMockDb() {
|
|
90
|
-
const mockPrepare = vi.fn().mockReturnValue({
|
|
91
|
-
run: vi.fn(),
|
|
92
|
-
get: vi.fn(),
|
|
93
|
-
all: vi.fn(),
|
|
94
|
-
});
|
|
95
|
-
return {
|
|
96
|
-
prepare: mockPrepare,
|
|
97
|
-
close: vi.fn(),
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ------------------------------------
|
|
102
|
-
// findTranscriptDir logic tests
|
|
103
|
-
// (tested indirectly via import side-effect + process.env)
|
|
104
|
-
// ------------------------------------
|
|
105
|
-
|
|
106
|
-
describe('findTranscriptDir logic', () => {
|
|
107
|
-
const originalHome = process.env.HOME;
|
|
108
|
-
const originalEnv = process.env;
|
|
109
|
-
|
|
110
|
-
beforeEach(() => {
|
|
111
|
-
vi.clearAllMocks();
|
|
112
|
-
process.env = { ...originalEnv, HOME: '/home/testuser' };
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
afterEach(() => {
|
|
116
|
-
process.env = originalEnv;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('uses HOME env variable and project root to build candidate path', () => {
|
|
120
|
-
// The function resolves: HOME/.claude/projects/<escaped-project-root>
|
|
121
|
-
// projectRoot = /home/user/my-project
|
|
122
|
-
// escaped = -home-user-my-project
|
|
123
|
-
const escapedPath = '/home/user/my-project'.replace(/\//g, '-');
|
|
124
|
-
const expectedPath = `/home/testuser/.claude/projects/${escapedPath}`;
|
|
125
|
-
|
|
126
|
-
// Candidate exists on first try
|
|
127
|
-
mockExistsSync.mockImplementation((p) => p === expectedPath);
|
|
128
|
-
mockGetMemoryDb.mockReturnValue(makeMockDb() as ReturnType<typeof getMemoryDb>);
|
|
129
|
-
mockReaddirSync.mockReturnValue([]);
|
|
130
|
-
|
|
131
|
-
// Verify the escaped path calculation logic is correct
|
|
132
|
-
expect(escapedPath).toBe('-home-user-my-project');
|
|
133
|
-
expect(expectedPath).toContain('.claude/projects');
|
|
134
|
-
expect(mockExistsSync).toBeDefined();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('falls back to scanning .claude/projects/ when candidate does not exist', () => {
|
|
138
|
-
const escapedPath = '/home/user/my-project'.replace(/\//g, '-');
|
|
139
|
-
const candidatePath = `/home/testuser/.claude/projects/${escapedPath}`;
|
|
140
|
-
const projectsDir = '/home/testuser/.claude/projects';
|
|
141
|
-
|
|
142
|
-
// Candidate does not exist, projects dir does
|
|
143
|
-
mockExistsSync.mockImplementation((p) => {
|
|
144
|
-
if (p === candidatePath) return false;
|
|
145
|
-
if (p === projectsDir) return true;
|
|
146
|
-
return false;
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// Fallback finds a match by project name
|
|
150
|
-
mockReaddirSync.mockImplementation((p) => {
|
|
151
|
-
if (p === projectsDir) return ['-home-user-my-project', '-other-project'] as unknown as ReturnType<typeof readdirSync>;
|
|
152
|
-
return [] as unknown as ReturnType<typeof readdirSync>;
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// Verify the project name matching logic
|
|
156
|
-
const projectName = 'my-project'; // basename of /home/user/my-project
|
|
157
|
-
const entries = ['-home-user-my-project', '-other-project'];
|
|
158
|
-
const match = entries.find(e => e.includes(projectName));
|
|
159
|
-
expect(match).toBe('-home-user-my-project');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('returns candidate path even when it does not exist (final fallback)', () => {
|
|
163
|
-
const escapedPath = '/home/user/my-project'.replace(/\//g, '-');
|
|
164
|
-
const candidatePath = `/home/testuser/.claude/projects/${escapedPath}`;
|
|
165
|
-
const projectsDir = '/home/testuser/.claude/projects';
|
|
166
|
-
|
|
167
|
-
// Neither candidate nor projectsDir exists
|
|
168
|
-
mockExistsSync.mockReturnValue(false);
|
|
169
|
-
|
|
170
|
-
// The function returns candidate in both cases
|
|
171
|
-
expect(mockExistsSync(candidatePath)).toBe(false);
|
|
172
|
-
expect(mockExistsSync(projectsDir)).toBe(false);
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// ------------------------------------
|
|
177
|
-
// Transcript processing flow tests
|
|
178
|
-
// ------------------------------------
|
|
179
|
-
|
|
180
|
-
describe('transcript processing flow', () => {
|
|
181
|
-
let mockDb: ReturnType<typeof makeMockDb>;
|
|
182
|
-
|
|
183
|
-
beforeEach(() => {
|
|
184
|
-
vi.clearAllMocks();
|
|
185
|
-
mockDb = makeMockDb();
|
|
186
|
-
mockGetMemoryDb.mockReturnValue(mockDb as ReturnType<typeof getMemoryDb>);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('creates a session and processes observations from a single transcript', async () => {
|
|
190
|
-
const sessionId = 'abc12345-session-1';
|
|
191
|
-
const filePath = `/tmp/.claude/projects/${sessionId}.jsonl`;
|
|
192
|
-
|
|
193
|
-
const mockEntries = [
|
|
194
|
-
{
|
|
195
|
-
type: 'user' as const,
|
|
196
|
-
sessionId,
|
|
197
|
-
gitBranch: 'main',
|
|
198
|
-
timestamp: '2026-01-01T10:00:00Z',
|
|
199
|
-
message: {
|
|
200
|
-
role: 'user' as const,
|
|
201
|
-
content: [{ type: 'text' as const, text: 'Fix the login bug' }],
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
type: 'assistant' as const,
|
|
206
|
-
sessionId,
|
|
207
|
-
timestamp: '2026-01-01T10:01:00Z',
|
|
208
|
-
message: {
|
|
209
|
-
role: 'assistant' as const,
|
|
210
|
-
content: [{ type: 'text' as const, text: 'Fixed the login bug by updating auth flow' }],
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
];
|
|
214
|
-
|
|
215
|
-
const mockObservations = [
|
|
216
|
-
{
|
|
217
|
-
type: 'bugfix',
|
|
218
|
-
title: 'Fixed login auth flow',
|
|
219
|
-
detail: 'Updated session handling',
|
|
220
|
-
visibility: 'public' as const,
|
|
221
|
-
opts: { importance: 3 },
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
type: 'decision',
|
|
225
|
-
title: 'Use JWT tokens for session management',
|
|
226
|
-
detail: 'Better security than cookies',
|
|
227
|
-
visibility: 'public' as const,
|
|
228
|
-
opts: { importance: 5 },
|
|
229
|
-
},
|
|
230
|
-
];
|
|
231
|
-
|
|
232
|
-
const mockUserMessages = [
|
|
233
|
-
{ text: 'Fix the login bug', timestamp: '2026-01-01T10:00:00Z' },
|
|
234
|
-
];
|
|
235
|
-
|
|
236
|
-
mockParseTranscript.mockResolvedValue(mockEntries);
|
|
237
|
-
mockExtractUserMessages.mockReturnValue(mockUserMessages);
|
|
238
|
-
mockExtractObservationsFromEntries.mockReturnValue(mockObservations);
|
|
239
|
-
mockAddObservation.mockReturnValue(1);
|
|
240
|
-
mockAddUserPrompt.mockReturnValue(1);
|
|
241
|
-
|
|
242
|
-
// Simulate the processing logic
|
|
243
|
-
const entries = await mockParseTranscript(filePath);
|
|
244
|
-
expect(entries).toHaveLength(2);
|
|
245
|
-
|
|
246
|
-
const firstEntry = entries.find(e => e.sessionId);
|
|
247
|
-
expect(firstEntry?.gitBranch).toBe('main');
|
|
248
|
-
expect(firstEntry?.timestamp).toBe('2026-01-01T10:00:00Z');
|
|
249
|
-
|
|
250
|
-
// Session creation
|
|
251
|
-
mockCreateSession(mockDb as ReturnType<typeof getMemoryDb>, sessionId, { branch: 'main' });
|
|
252
|
-
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
253
|
-
expect.anything(),
|
|
254
|
-
sessionId,
|
|
255
|
-
{ branch: 'main' }
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
// User prompts
|
|
259
|
-
const userMessages = mockExtractUserMessages(entries);
|
|
260
|
-
expect(userMessages).toHaveLength(1);
|
|
261
|
-
mockAddUserPrompt(mockDb as ReturnType<typeof getMemoryDb>, sessionId, userMessages[0].text, 1);
|
|
262
|
-
expect(mockAddUserPrompt).toHaveBeenCalledWith(expect.anything(), sessionId, 'Fix the login bug', 1);
|
|
263
|
-
|
|
264
|
-
// Observations
|
|
265
|
-
const observations = mockExtractObservationsFromEntries(entries);
|
|
266
|
-
expect(observations).toHaveLength(2);
|
|
267
|
-
|
|
268
|
-
for (const obs of observations) {
|
|
269
|
-
if (obs.type === 'failed_attempt') {
|
|
270
|
-
mockDeduplicateFailedAttempt(
|
|
271
|
-
mockDb as ReturnType<typeof getMemoryDb>,
|
|
272
|
-
sessionId,
|
|
273
|
-
obs.title,
|
|
274
|
-
obs.detail,
|
|
275
|
-
obs.opts
|
|
276
|
-
);
|
|
277
|
-
} else {
|
|
278
|
-
mockAddObservation(
|
|
279
|
-
mockDb as ReturnType<typeof getMemoryDb>,
|
|
280
|
-
sessionId,
|
|
281
|
-
obs.type,
|
|
282
|
-
obs.title,
|
|
283
|
-
obs.detail,
|
|
284
|
-
obs.opts
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
expect(mockAddObservation).toHaveBeenCalledTimes(2);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('skips empty transcripts', async () => {
|
|
293
|
-
const filePath = '/tmp/.claude/projects/empty-session.jsonl';
|
|
294
|
-
mockParseTranscript.mockResolvedValue([]);
|
|
295
|
-
|
|
296
|
-
const entries = await mockParseTranscript(filePath);
|
|
297
|
-
expect(entries).toHaveLength(0);
|
|
298
|
-
|
|
299
|
-
// No further processing should occur
|
|
300
|
-
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
301
|
-
expect(mockAddObservation).not.toHaveBeenCalled();
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('uses deduplicateFailedAttempt for failed_attempt observations', async () => {
|
|
305
|
-
const filePath = '/tmp/.claude/projects/session-with-failures.jsonl';
|
|
306
|
-
const sessionId = 'session-with-failures';
|
|
307
|
-
|
|
308
|
-
const mockEntries = [
|
|
309
|
-
{
|
|
310
|
-
type: 'assistant' as const,
|
|
311
|
-
sessionId,
|
|
312
|
-
timestamp: '2026-01-02T10:00:00Z',
|
|
313
|
-
message: {
|
|
314
|
-
role: 'assistant' as const,
|
|
315
|
-
content: [{ type: 'text' as const, text: 'This approach failed due to regex issue' }],
|
|
316
|
-
},
|
|
317
|
-
},
|
|
318
|
-
];
|
|
319
|
-
|
|
320
|
-
const mockObservations = [
|
|
321
|
-
{
|
|
322
|
-
type: 'failed_attempt',
|
|
323
|
-
title: 'Regex parser fails on nested braces',
|
|
324
|
-
detail: 'Stopped at first closing brace',
|
|
325
|
-
visibility: 'public' as const,
|
|
326
|
-
opts: { importance: 5 },
|
|
327
|
-
},
|
|
328
|
-
];
|
|
329
|
-
|
|
330
|
-
mockParseTranscript.mockResolvedValue(mockEntries);
|
|
331
|
-
mockExtractUserMessages.mockReturnValue([]);
|
|
332
|
-
mockExtractObservationsFromEntries.mockReturnValue(mockObservations);
|
|
333
|
-
|
|
334
|
-
const entries = await mockParseTranscript(filePath);
|
|
335
|
-
const observations = mockExtractObservationsFromEntries(entries);
|
|
336
|
-
|
|
337
|
-
for (const obs of observations) {
|
|
338
|
-
if (obs.type === 'failed_attempt') {
|
|
339
|
-
mockDeduplicateFailedAttempt(
|
|
340
|
-
mockDb as ReturnType<typeof getMemoryDb>,
|
|
341
|
-
sessionId,
|
|
342
|
-
obs.title,
|
|
343
|
-
obs.detail,
|
|
344
|
-
obs.opts
|
|
345
|
-
);
|
|
346
|
-
} else {
|
|
347
|
-
mockAddObservation(
|
|
348
|
-
mockDb as ReturnType<typeof getMemoryDb>,
|
|
349
|
-
sessionId,
|
|
350
|
-
obs.type,
|
|
351
|
-
obs.title,
|
|
352
|
-
obs.detail,
|
|
353
|
-
obs.opts
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
expect(mockDeduplicateFailedAttempt).toHaveBeenCalledWith(
|
|
359
|
-
expect.anything(),
|
|
360
|
-
sessionId,
|
|
361
|
-
'Regex parser fails on nested braces',
|
|
362
|
-
'Stopped at first closing brace',
|
|
363
|
-
{ importance: 5 }
|
|
364
|
-
);
|
|
365
|
-
expect(mockAddObservation).not.toHaveBeenCalled();
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
it('generates a summary when observations exist', async () => {
|
|
369
|
-
const observations = [
|
|
370
|
-
{ type: 'feature', title: 'Add login page', detail: null, visibility: 'public' as const, opts: {} },
|
|
371
|
-
{ type: 'decision', title: 'Use JWT tokens', detail: null, visibility: 'public' as const, opts: {} },
|
|
372
|
-
{ type: 'failed_attempt', title: 'Cookie approach failed', detail: null, visibility: 'public' as const, opts: {} },
|
|
373
|
-
];
|
|
374
|
-
|
|
375
|
-
const userMessages = [{ text: 'Implement authentication', timestamp: '2026-01-01T10:00:00Z' }];
|
|
376
|
-
|
|
377
|
-
// Simulate summary generation logic
|
|
378
|
-
const completed = observations
|
|
379
|
-
.filter(o => ['feature', 'bugfix', 'refactor'].includes(o.type))
|
|
380
|
-
.map(o => `- ${o.title}`)
|
|
381
|
-
.join('\n');
|
|
382
|
-
|
|
383
|
-
const decisions = observations
|
|
384
|
-
.filter(o => o.type === 'decision')
|
|
385
|
-
.map(o => `- ${o.title}`)
|
|
386
|
-
.join('\n');
|
|
387
|
-
|
|
388
|
-
const failedAttempts = observations
|
|
389
|
-
.filter(o => o.type === 'failed_attempt')
|
|
390
|
-
.map(o => `- ${o.title}`)
|
|
391
|
-
.join('\n');
|
|
392
|
-
|
|
393
|
-
expect(completed).toBe('- Add login page');
|
|
394
|
-
expect(decisions).toBe('- Use JWT tokens');
|
|
395
|
-
expect(failedAttempts).toBe('- Cookie approach failed');
|
|
396
|
-
|
|
397
|
-
mockAddSummary(mockDb as ReturnType<typeof getMemoryDb>, 'test-session', {
|
|
398
|
-
request: userMessages[0].text.slice(0, 500),
|
|
399
|
-
completed: completed || undefined,
|
|
400
|
-
decisions: decisions || undefined,
|
|
401
|
-
failedAttempts: failedAttempts || undefined,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
expect(mockAddSummary).toHaveBeenCalledWith(
|
|
405
|
-
expect.anything(),
|
|
406
|
-
'test-session',
|
|
407
|
-
{
|
|
408
|
-
request: 'Implement authentication',
|
|
409
|
-
completed: '- Add login page',
|
|
410
|
-
decisions: '- Use JWT tokens',
|
|
411
|
-
failedAttempts: '- Cookie approach failed',
|
|
412
|
-
}
|
|
413
|
-
);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it('truncates long user prompts to 5000 characters', () => {
|
|
417
|
-
const longText = 'x'.repeat(6000);
|
|
418
|
-
const truncated = longText.slice(0, 5000);
|
|
419
|
-
expect(truncated).toHaveLength(5000);
|
|
420
|
-
expect(longText.slice(0, 5000)).toHaveLength(5000);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it('processes at most 50 user messages per session', () => {
|
|
424
|
-
const manyMessages = Array.from({ length: 75 }, (_, i) => ({
|
|
425
|
-
text: `Prompt ${i + 1}`,
|
|
426
|
-
timestamp: `2026-01-01T10:${String(i).padStart(2, '0')}:00Z`,
|
|
427
|
-
}));
|
|
428
|
-
|
|
429
|
-
const limit = Math.min(manyMessages.length, 50);
|
|
430
|
-
expect(limit).toBe(50);
|
|
431
|
-
expect(manyMessages.slice(0, limit)).toHaveLength(50);
|
|
432
|
-
});
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
// ------------------------------------
|
|
436
|
-
// sessionId extraction tests
|
|
437
|
-
// ------------------------------------
|
|
438
|
-
|
|
439
|
-
describe('sessionId extraction from file path', () => {
|
|
440
|
-
it('extracts session id from full file path', () => {
|
|
441
|
-
const filePath = '/home/user/.claude/projects/-home-user-my-project/abc123def456.jsonl';
|
|
442
|
-
const sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown';
|
|
443
|
-
expect(sessionId).toBe('abc123def456');
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it('returns empty string for empty path (pop returns empty string, not undefined)', () => {
|
|
447
|
-
// ''.split('/').pop() returns '' (not undefined), so ?? 'unknown' does not fire
|
|
448
|
-
const filePath = '';
|
|
449
|
-
const sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown';
|
|
450
|
-
// empty string is falsy but not null/undefined, so nullish coalescing returns ''
|
|
451
|
-
expect(sessionId).toBe('');
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it('handles session IDs with dashes and underscores', () => {
|
|
455
|
-
const filePath = '/tmp/sessions/my-session_2026-01-01.jsonl';
|
|
456
|
-
const sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown';
|
|
457
|
-
expect(sessionId).toBe('my-session_2026-01-01');
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// ------------------------------------
|
|
462
|
-
// Error handling tests
|
|
463
|
-
// ------------------------------------
|
|
464
|
-
|
|
465
|
-
describe('error handling', () => {
|
|
466
|
-
let mockDb: ReturnType<typeof makeMockDb>;
|
|
467
|
-
|
|
468
|
-
beforeEach(() => {
|
|
469
|
-
vi.clearAllMocks();
|
|
470
|
-
mockDb = makeMockDb();
|
|
471
|
-
mockGetMemoryDb.mockReturnValue(mockDb as ReturnType<typeof getMemoryDb>);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('handles parseTranscript failure gracefully (continues to next file)', async () => {
|
|
475
|
-
const file1 = '/tmp/sessions/session1.jsonl';
|
|
476
|
-
const file2 = '/tmp/sessions/session2.jsonl';
|
|
477
|
-
|
|
478
|
-
mockParseTranscript
|
|
479
|
-
.mockRejectedValueOnce(new Error('JSONL parse error'))
|
|
480
|
-
.mockResolvedValueOnce([
|
|
481
|
-
{
|
|
482
|
-
type: 'user' as const,
|
|
483
|
-
sessionId: 'session2',
|
|
484
|
-
gitBranch: 'main',
|
|
485
|
-
timestamp: '2026-01-02T10:00:00Z',
|
|
486
|
-
message: {
|
|
487
|
-
role: 'user' as const,
|
|
488
|
-
content: [{ type: 'text' as const, text: 'Fix it' }],
|
|
489
|
-
},
|
|
490
|
-
},
|
|
491
|
-
]);
|
|
492
|
-
|
|
493
|
-
mockExtractUserMessages.mockReturnValue([{ text: 'Fix it' }]);
|
|
494
|
-
mockExtractObservationsFromEntries.mockReturnValue([]);
|
|
495
|
-
|
|
496
|
-
// Process file 1: should fail
|
|
497
|
-
let session1Error: Error | null = null;
|
|
498
|
-
try {
|
|
499
|
-
await mockParseTranscript(file1);
|
|
500
|
-
} catch (e) {
|
|
501
|
-
session1Error = e as Error;
|
|
502
|
-
}
|
|
503
|
-
expect(session1Error?.message).toBe('JSONL parse error');
|
|
504
|
-
|
|
505
|
-
// Process file 2: should succeed
|
|
506
|
-
const entries2 = await mockParseTranscript(file2);
|
|
507
|
-
expect(entries2).toHaveLength(1);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
it('handles addObservation errors per-observation (continues to next)', () => {
|
|
511
|
-
const sessionId = 'test-session';
|
|
512
|
-
mockAddObservation
|
|
513
|
-
.mockImplementationOnce(() => { throw new Error('DB constraint'); })
|
|
514
|
-
.mockReturnValueOnce(2);
|
|
515
|
-
|
|
516
|
-
const observations = [
|
|
517
|
-
{ type: 'bugfix', title: 'Fix 1', detail: null, opts: {} },
|
|
518
|
-
{ type: 'feature', title: 'Feature 1', detail: null, opts: {} },
|
|
519
|
-
];
|
|
520
|
-
|
|
521
|
-
let successCount = 0;
|
|
522
|
-
for (const obs of observations) {
|
|
523
|
-
try {
|
|
524
|
-
mockAddObservation(
|
|
525
|
-
mockDb as ReturnType<typeof getMemoryDb>,
|
|
526
|
-
sessionId,
|
|
527
|
-
obs.type,
|
|
528
|
-
obs.title,
|
|
529
|
-
obs.detail,
|
|
530
|
-
obs.opts
|
|
531
|
-
);
|
|
532
|
-
successCount++;
|
|
533
|
-
} catch (_e) {
|
|
534
|
-
// Skip on error - same behavior as backfill-sessions.ts
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
expect(successCount).toBe(1);
|
|
539
|
-
expect(mockAddObservation).toHaveBeenCalledTimes(2);
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it('ensures db.close() is always called (finally block)', () => {
|
|
543
|
-
// Even if processing throws, close should be called
|
|
544
|
-
mockGetMemoryDb.mockReturnValue(mockDb as ReturnType<typeof getMemoryDb>);
|
|
545
|
-
|
|
546
|
-
const db = mockGetMemoryDb();
|
|
547
|
-
try {
|
|
548
|
-
throw new Error('Unexpected error during processing');
|
|
549
|
-
} catch (_e) {
|
|
550
|
-
// Handled
|
|
551
|
-
} finally {
|
|
552
|
-
db.close();
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
expect(mockDb.close).toHaveBeenCalledTimes(1);
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it('handles readdirSync failure for transcript directory', () => {
|
|
559
|
-
mockExistsSync.mockReturnValue(true);
|
|
560
|
-
mockReaddirSync.mockImplementation(() => {
|
|
561
|
-
throw new Error('ENOENT: no such file or directory');
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
let caughtError: Error | null = null;
|
|
565
|
-
let files: string[] = [];
|
|
566
|
-
try {
|
|
567
|
-
files = (readdirSync('/nonexistent') as unknown as string[])
|
|
568
|
-
.filter((f: string) => f.endsWith('.jsonl'));
|
|
569
|
-
} catch (e) {
|
|
570
|
-
caughtError = e as Error;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
expect(caughtError).not.toBeNull();
|
|
574
|
-
expect(caughtError?.message).toContain('ENOENT');
|
|
575
|
-
expect(files).toHaveLength(0);
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it('handles addSummary errors without propagating', () => {
|
|
579
|
-
mockAddSummary.mockImplementationOnce(() => { throw new Error('Summary duplicate'); });
|
|
580
|
-
|
|
581
|
-
let threw = false;
|
|
582
|
-
try {
|
|
583
|
-
mockAddSummary(mockDb as ReturnType<typeof getMemoryDb>, 'test-session', {
|
|
584
|
-
request: 'Fix the bug',
|
|
585
|
-
});
|
|
586
|
-
} catch (_e) {
|
|
587
|
-
threw = true;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// The backfill script silently skips summary errors
|
|
591
|
-
expect(threw).toBe(true);
|
|
592
|
-
expect(mockAddSummary).toHaveBeenCalledOnce();
|
|
593
|
-
});
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
// ------------------------------------
|
|
597
|
-
// MAX_SESSIONS cap tests
|
|
598
|
-
// ------------------------------------
|
|
599
|
-
|
|
600
|
-
describe('MAX_SESSIONS cap', () => {
|
|
601
|
-
it('caps session processing at 20 files', () => {
|
|
602
|
-
const MAX_SESSIONS = 20;
|
|
603
|
-
const allFiles = Array.from({ length: 35 }, (_, i) => ({
|
|
604
|
-
name: `session-${i}.jsonl`,
|
|
605
|
-
mtime: Date.now() - i * 1000,
|
|
606
|
-
}));
|
|
607
|
-
|
|
608
|
-
const processed = allFiles
|
|
609
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
610
|
-
.slice(0, MAX_SESSIONS);
|
|
611
|
-
|
|
612
|
-
expect(processed).toHaveLength(20);
|
|
613
|
-
// Most recent should be first
|
|
614
|
-
expect(processed[0].name).toBe('session-0.jsonl');
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
it('sorts files by modification time (most recent first)', () => {
|
|
618
|
-
const files = [
|
|
619
|
-
{ name: 'old.jsonl', mtime: 1000 },
|
|
620
|
-
{ name: 'newest.jsonl', mtime: 3000 },
|
|
621
|
-
{ name: 'middle.jsonl', mtime: 2000 },
|
|
622
|
-
];
|
|
623
|
-
|
|
624
|
-
const sorted = files.sort((a, b) => b.mtime - a.mtime);
|
|
625
|
-
expect(sorted[0].name).toBe('newest.jsonl');
|
|
626
|
-
expect(sorted[1].name).toBe('middle.jsonl');
|
|
627
|
-
expect(sorted[2].name).toBe('old.jsonl');
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it('only processes .jsonl files', () => {
|
|
631
|
-
const allFiles = ['session1.jsonl', 'README.md', 'session2.jsonl', 'config.json', 'session3.jsonl'];
|
|
632
|
-
const jsonlFiles = allFiles.filter(f => f.endsWith('.jsonl'));
|
|
633
|
-
expect(jsonlFiles).toHaveLength(3);
|
|
634
|
-
expect(jsonlFiles).toEqual(['session1.jsonl', 'session2.jsonl', 'session3.jsonl']);
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
// ------------------------------------
|
|
639
|
-
// Summary generation logic tests
|
|
640
|
-
// ------------------------------------
|
|
641
|
-
|
|
642
|
-
describe('summary generation logic', () => {
|
|
643
|
-
it('only generates summary when observations exist', () => {
|
|
644
|
-
const observations: { type: string; title: string }[] = [];
|
|
645
|
-
const shouldGenerate = observations.length > 0;
|
|
646
|
-
expect(shouldGenerate).toBe(false);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
it('generates summary for non-empty observation lists', () => {
|
|
650
|
-
const observations = [
|
|
651
|
-
{ type: 'feature', title: 'New auth module' },
|
|
652
|
-
];
|
|
653
|
-
const shouldGenerate = observations.length > 0;
|
|
654
|
-
expect(shouldGenerate).toBe(true);
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
it('includes completed items from feature/bugfix/refactor types', () => {
|
|
658
|
-
const observations = [
|
|
659
|
-
{ type: 'feature', title: 'Add login' },
|
|
660
|
-
{ type: 'bugfix', title: 'Fix token expiry' },
|
|
661
|
-
{ type: 'refactor', title: 'Clean up auth helpers' },
|
|
662
|
-
{ type: 'decision', title: 'Use JWT' },
|
|
663
|
-
{ type: 'failed_attempt', title: 'Cookie approach failed' },
|
|
664
|
-
];
|
|
665
|
-
|
|
666
|
-
const completed = observations
|
|
667
|
-
.filter(o => ['feature', 'bugfix', 'refactor'].includes(o.type))
|
|
668
|
-
.map(o => `- ${o.title}`)
|
|
669
|
-
.join('\n');
|
|
670
|
-
|
|
671
|
-
expect(completed).toBe('- Add login\n- Fix token expiry\n- Clean up auth helpers');
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
it('uses undefined for empty completed/decisions/failedAttempts in summary', () => {
|
|
675
|
-
const observations = [{ type: 'discovery', title: 'Found legacy code' }];
|
|
676
|
-
|
|
677
|
-
const completed = observations
|
|
678
|
-
.filter(o => ['feature', 'bugfix', 'refactor'].includes(o.type))
|
|
679
|
-
.map(o => `- ${o.title}`)
|
|
680
|
-
.join('\n');
|
|
681
|
-
|
|
682
|
-
const decisions = observations
|
|
683
|
-
.filter(o => o.type === 'decision')
|
|
684
|
-
.map(o => `- ${o.title}`)
|
|
685
|
-
.join('\n');
|
|
686
|
-
|
|
687
|
-
expect(completed || undefined).toBeUndefined();
|
|
688
|
-
expect(decisions || undefined).toBeUndefined();
|
|
689
|
-
});
|
|
690
|
-
});
|