claude-mneme 2.9.1
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/.claude-plugin/plugin.json +17 -0
- package/CLAUDE.md +98 -0
- package/CONFIG_REFERENCE.md +495 -0
- package/README.md +40 -0
- package/commands/entity.md +64 -0
- package/commands/forget.md +69 -0
- package/commands/remember.md +60 -0
- package/commands/status.md +90 -0
- package/commands/summarize.md +69 -0
- package/hooks/hooks.json +123 -0
- package/package.json +12 -0
- package/scripts/mem-add.mjs +59 -0
- package/scripts/mem-entity.mjs +143 -0
- package/scripts/mem-forget.mjs +245 -0
- package/scripts/mem-status.mjs +319 -0
- package/scripts/mem-summarize.mjs +338 -0
- package/scripts/post-compact.mjs +132 -0
- package/scripts/post-tool-use.mjs +353 -0
- package/scripts/pre-compact.mjs +491 -0
- package/scripts/session-start.mjs +283 -0
- package/scripts/session-stop.mjs +31 -0
- package/scripts/stop-capture.mjs +294 -0
- package/scripts/subagent-stop.mjs +203 -0
- package/scripts/summarize.mjs +428 -0
- package/scripts/sync.mjs +609 -0
- package/scripts/user-prompt-submit.mjs +77 -0
- package/scripts/utils.mjs +2142 -0
- package/scripts/utils.test.mjs +1465 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for claude-mneme utility functions
|
|
3
|
+
*
|
|
4
|
+
* Run: node --test plugin/scripts/utils.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
|
|
10
|
+
// Import the functions under test — some are not exported, so we test
|
|
11
|
+
// the exported wrappers that exercise them.
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, appendFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { before, after } from 'node:test';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
escapeAttr,
|
|
19
|
+
splitSentences,
|
|
20
|
+
extractiveSummarize,
|
|
21
|
+
deduplicateEntries,
|
|
22
|
+
extractEntitiesFromEntry,
|
|
23
|
+
emptyEntityIndex,
|
|
24
|
+
emptyStructuredSummary,
|
|
25
|
+
formatEntry,
|
|
26
|
+
formatEntriesForSummary,
|
|
27
|
+
renderSummaryToMarkdown,
|
|
28
|
+
withFileLock,
|
|
29
|
+
loadConfig,
|
|
30
|
+
flushPendingLog,
|
|
31
|
+
ensureMemoryDirs,
|
|
32
|
+
calculateRecencyScore,
|
|
33
|
+
calculateFileRelevanceScore,
|
|
34
|
+
calculateTypePriorityScore,
|
|
35
|
+
calculateEntityRelevanceScore,
|
|
36
|
+
pruneEntityIndex,
|
|
37
|
+
extractFilePaths,
|
|
38
|
+
isFileFalsePositive,
|
|
39
|
+
extractFunctionNames,
|
|
40
|
+
extractErrorMessages,
|
|
41
|
+
extractPackageNames,
|
|
42
|
+
isValidPackageName,
|
|
43
|
+
stripMarkdown,
|
|
44
|
+
} from './utils.mjs';
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// escapeAttr
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
describe('escapeAttr', () => {
|
|
51
|
+
it('passes through safe strings unchanged', () => {
|
|
52
|
+
assert.equal(escapeAttr('hello world'), 'hello world');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('escapes ampersands', () => {
|
|
56
|
+
assert.equal(escapeAttr('a&b'), 'a&b');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('escapes double quotes', () => {
|
|
60
|
+
assert.equal(escapeAttr('say "hi"'), 'say "hi"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('escapes angle brackets', () => {
|
|
64
|
+
assert.equal(escapeAttr('<script>'), '<script>');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('escapes all special chars together', () => {
|
|
68
|
+
assert.equal(escapeAttr('a & "b" <c>'), 'a & "b" <c>');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('coerces non-strings', () => {
|
|
72
|
+
assert.equal(escapeAttr(42), '42');
|
|
73
|
+
assert.equal(escapeAttr(null), 'null');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// splitSentences
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
describe('splitSentences', () => {
|
|
82
|
+
it('splits simple sentences', () => {
|
|
83
|
+
const result = splitSentences('First sentence. Second sentence.');
|
|
84
|
+
assert.equal(result.length, 2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('handles bullet lists', () => {
|
|
88
|
+
const result = splitSentences('- item one\n- item two\n- item three');
|
|
89
|
+
assert.equal(result.length, 3);
|
|
90
|
+
assert.equal(result[0], 'item one');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns single unit for short text', () => {
|
|
94
|
+
const result = splitSentences('just a phrase');
|
|
95
|
+
assert.deepEqual(result, ['just a phrase']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('handles empty input', () => {
|
|
99
|
+
const result = splitSentences('');
|
|
100
|
+
assert.deepEqual(result, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('splits paragraphs', () => {
|
|
104
|
+
const result = splitSentences('Para one.\n\nPara two.');
|
|
105
|
+
assert.equal(result.length, 2);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// extractiveSummarize
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
describe('extractiveSummarize', () => {
|
|
114
|
+
const defaultConfig = {
|
|
115
|
+
maxSummarySentences: 4,
|
|
116
|
+
actionWords: [
|
|
117
|
+
'fixed', 'added', 'created', 'updated', 'removed', 'deleted',
|
|
118
|
+
'implemented', 'refactored', 'resolved'
|
|
119
|
+
],
|
|
120
|
+
reasoningWords: [
|
|
121
|
+
'because', 'instead', 'decided', "can't", 'avoid', 'prefer', 'constraint'
|
|
122
|
+
]
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
it('returns empty for empty input', () => {
|
|
126
|
+
assert.equal(extractiveSummarize('', defaultConfig), '');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('keeps action sentences', () => {
|
|
130
|
+
const text = 'Fixed the authentication bug in login flow. The weather is nice today.';
|
|
131
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 1 });
|
|
132
|
+
assert.ok(result.includes('Fixed'), `Expected "Fixed" in: ${result}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('respects maxSummarySentences', () => {
|
|
136
|
+
const text = 'Added auth. Fixed bug. Created test. Updated docs. Removed dead code.';
|
|
137
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 2 });
|
|
138
|
+
const sentences = result.split(/[.!?]\s+/).filter(s => s.trim());
|
|
139
|
+
assert.ok(sentences.length <= 3, `Expected <=3 sentences, got ${sentences.length}`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('strips lead-in phrases', () => {
|
|
143
|
+
const text = "Here's what I did. Fixed the authentication bug.";
|
|
144
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 2 });
|
|
145
|
+
assert.ok(!result.startsWith("Here's"), `Should strip lead-in: ${result}`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('always keeps the first sentence', () => {
|
|
149
|
+
// First sentence has no action/reasoning words but should still be kept
|
|
150
|
+
const text = 'The system has three components. Added auth. Fixed bug. Created test. Updated docs.';
|
|
151
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 2 });
|
|
152
|
+
assert.ok(result.includes('three components'), `First sentence should be kept: ${result}`);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('scores reasoning words', () => {
|
|
156
|
+
const text = "We can't use Redis because serialization overhead is too high. The sky is blue. Added a log line.";
|
|
157
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 2 });
|
|
158
|
+
assert.ok(result.includes("can't use Redis"), `Should keep reasoning sentence: ${result}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('scores entity references (file paths)', () => {
|
|
162
|
+
const text = 'Something happened. The change is in src/auth.ts for the login module. Nothing else matters.';
|
|
163
|
+
const result = extractiveSummarize(text, { ...defaultConfig, maxSummarySentences: 2 });
|
|
164
|
+
assert.ok(result.includes('src/auth.ts'), `Should keep entity sentence: ${result}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('works with empty word lists', () => {
|
|
168
|
+
const text = 'First sentence. Second sentence. Third sentence.';
|
|
169
|
+
const result = extractiveSummarize(text, { maxSummarySentences: 2, actionWords: [], reasoningWords: [] });
|
|
170
|
+
// Should still return something (first sentence + one more)
|
|
171
|
+
assert.ok(result.includes('First sentence'), `Should keep first sentence: ${result}`);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// deduplicateEntries
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
describe('deduplicateEntries', () => {
|
|
180
|
+
const baseTs = '2025-02-04T10:00:00Z';
|
|
181
|
+
function ts(minutesOffset) {
|
|
182
|
+
return new Date(new Date(baseTs).getTime() + minutesOffset * 60000).toISOString();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it('returns single entry unchanged', () => {
|
|
186
|
+
const entries = [{ ts: baseTs, type: 'prompt', content: 'hello' }];
|
|
187
|
+
const result = deduplicateEntries(entries);
|
|
188
|
+
assert.equal(result.length, 1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('deduplicates entries within time window', () => {
|
|
192
|
+
const entries = [
|
|
193
|
+
{ ts: ts(0), type: 'prompt', content: 'Fix the auth bug' },
|
|
194
|
+
{ ts: ts(1), type: 'task', content: 'Fix auth bug', action: 'created' },
|
|
195
|
+
{ ts: ts(2), type: 'commit', content: 'fix: auth bug in login flow' },
|
|
196
|
+
];
|
|
197
|
+
const result = deduplicateEntries(entries);
|
|
198
|
+
assert.equal(result.length, 1, 'Should deduplicate to 1 entry');
|
|
199
|
+
assert.equal(result[0].type, 'commit', 'Commit should win (highest priority)');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('preserves entries outside time window', () => {
|
|
203
|
+
const entries = [
|
|
204
|
+
{ ts: ts(0), type: 'prompt', content: 'First task' },
|
|
205
|
+
{ ts: ts(10), type: 'prompt', content: 'Second task' }, // 10 min apart > 5 min window
|
|
206
|
+
];
|
|
207
|
+
const result = deduplicateEntries(entries);
|
|
208
|
+
assert.equal(result.length, 2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('respects enabled=false', () => {
|
|
212
|
+
const entries = [
|
|
213
|
+
{ ts: ts(0), type: 'prompt', content: 'a' },
|
|
214
|
+
{ ts: ts(1), type: 'commit', content: 'b' },
|
|
215
|
+
];
|
|
216
|
+
const result = deduplicateEntries(entries, { deduplication: { enabled: false } });
|
|
217
|
+
assert.equal(result.length, 2);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('adds merged context when mergeContext is true', () => {
|
|
221
|
+
const entries = [
|
|
222
|
+
{ ts: ts(0), type: 'prompt', content: 'Fix auth' },
|
|
223
|
+
{ ts: ts(1), type: 'commit', content: 'fix: auth bug' },
|
|
224
|
+
];
|
|
225
|
+
const result = deduplicateEntries(entries, { deduplication: { mergeContext: true } });
|
|
226
|
+
assert.equal(result.length, 1);
|
|
227
|
+
assert.ok(result[0]._mergedFrom, 'Should have _mergedFrom metadata');
|
|
228
|
+
assert.ok(result[0]._mergedFrom.includes('prompt'), 'Should reference merged prompt type');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// extractEntitiesFromEntry
|
|
234
|
+
// ============================================================================
|
|
235
|
+
|
|
236
|
+
describe('extractEntitiesFromEntry', () => {
|
|
237
|
+
it('extracts file paths', () => {
|
|
238
|
+
const entry = { content: 'Updated src/auth.ts and utils/helpers.mjs' };
|
|
239
|
+
const result = extractEntitiesFromEntry(entry);
|
|
240
|
+
assert.ok(result.files.includes('src/auth.ts'), `Expected src/auth.ts in ${result.files}`);
|
|
241
|
+
assert.ok(result.files.includes('utils/helpers.mjs'), `Expected utils/helpers.mjs in ${result.files}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('extracts error messages', () => {
|
|
245
|
+
const entry = { content: 'Got TypeError: Cannot read property of undefined' };
|
|
246
|
+
const result = extractEntitiesFromEntry(entry);
|
|
247
|
+
assert.ok(result.errors.length > 0, 'Should extract at least one error');
|
|
248
|
+
assert.ok(result.errors[0].includes('TypeError'), `Expected TypeError in ${result.errors}`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('extracts package names', () => {
|
|
252
|
+
const entry = { content: 'npm install express @anthropic-ai/sdk lodash' };
|
|
253
|
+
const result = extractEntitiesFromEntry(entry);
|
|
254
|
+
assert.ok(result.packages, 'Should have packages key');
|
|
255
|
+
assert.ok(result.packages.some(p => p === 'express' || p.includes('express')),
|
|
256
|
+
`Expected express in ${JSON.stringify(result.packages)}`);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns empty object for empty content', () => {
|
|
260
|
+
// extractEntitiesFromEntry only includes keys when matches are found
|
|
261
|
+
const result = extractEntitiesFromEntry({ content: '' });
|
|
262
|
+
assert.equal(result.files, undefined);
|
|
263
|
+
assert.equal(result.functions, undefined);
|
|
264
|
+
assert.equal(result.errors, undefined);
|
|
265
|
+
assert.equal(result.packages, undefined);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('respects disabled categories', () => {
|
|
269
|
+
const entry = { content: 'Updated src/auth.ts with handleLogin function' };
|
|
270
|
+
const result = extractEntitiesFromEntry(entry, {
|
|
271
|
+
categories: { files: false, functions: true, errors: true, packages: true }
|
|
272
|
+
});
|
|
273
|
+
assert.equal(result.files, undefined, 'files should not be extracted when disabled');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// emptyStructuredSummary
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
describe('emptyStructuredSummary', () => {
|
|
282
|
+
it('returns correct shape', () => {
|
|
283
|
+
const summary = emptyStructuredSummary();
|
|
284
|
+
assert.ok(Array.isArray(summary.keyDecisions));
|
|
285
|
+
assert.ok(Array.isArray(summary.currentState));
|
|
286
|
+
assert.ok(Array.isArray(summary.recentWork));
|
|
287
|
+
assert.equal(summary.projectContext, '');
|
|
288
|
+
assert.equal(summary.lastUpdated, null);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// emptyEntityIndex
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
describe('emptyEntityIndex', () => {
|
|
297
|
+
it('returns correct shape', () => {
|
|
298
|
+
const index = emptyEntityIndex();
|
|
299
|
+
assert.ok(typeof index.files === 'object');
|
|
300
|
+
assert.ok(typeof index.functions === 'object');
|
|
301
|
+
assert.ok(typeof index.errors === 'object');
|
|
302
|
+
assert.ok(typeof index.packages === 'object');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ============================================================================
|
|
307
|
+
// formatEntry
|
|
308
|
+
// ============================================================================
|
|
309
|
+
|
|
310
|
+
describe('formatEntry', () => {
|
|
311
|
+
it('formats a commit entry', () => {
|
|
312
|
+
const result = formatEntry({
|
|
313
|
+
ts: '2025-02-04T10:00:00Z',
|
|
314
|
+
type: 'commit',
|
|
315
|
+
content: 'fix: auth bug'
|
|
316
|
+
});
|
|
317
|
+
assert.ok(result.includes('Commit'), `Expected "Commit" in: ${result}`);
|
|
318
|
+
assert.ok(result.includes('fix: auth bug'));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('formats a task entry with action', () => {
|
|
322
|
+
const result = formatEntry({
|
|
323
|
+
ts: '2025-02-04T10:00:00Z',
|
|
324
|
+
type: 'task',
|
|
325
|
+
action: 'completed',
|
|
326
|
+
subject: 'Fix auth bug'
|
|
327
|
+
});
|
|
328
|
+
assert.ok(result.includes('completed') || result.includes('Completed'));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('formats a prompt entry', () => {
|
|
332
|
+
const result = formatEntry({
|
|
333
|
+
ts: '2025-02-04T10:00:00Z',
|
|
334
|
+
type: 'prompt',
|
|
335
|
+
content: 'Fix the login bug'
|
|
336
|
+
});
|
|
337
|
+
assert.ok(result.includes('Fix the login bug'));
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// formatEntriesForSummary
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
describe('formatEntriesForSummary', () => {
|
|
346
|
+
it('formats JSON lines for summarization', () => {
|
|
347
|
+
const lines = [
|
|
348
|
+
JSON.stringify({ ts: '2025-02-04T10:00:00Z', type: 'commit', content: 'fix: auth' }),
|
|
349
|
+
JSON.stringify({ ts: '2025-02-04T10:01:00Z', type: 'prompt', content: 'Fix bugs' }),
|
|
350
|
+
];
|
|
351
|
+
const result = formatEntriesForSummary(lines);
|
|
352
|
+
assert.ok(result.includes('fix: auth'));
|
|
353
|
+
assert.ok(result.includes('Fix bugs'));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('handles empty input', () => {
|
|
357
|
+
const result = formatEntriesForSummary([]);
|
|
358
|
+
assert.equal(result, '');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// renderSummaryToMarkdown
|
|
364
|
+
// ============================================================================
|
|
365
|
+
|
|
366
|
+
describe('renderSummaryToMarkdown', () => {
|
|
367
|
+
it('renders project context', () => {
|
|
368
|
+
const summary = {
|
|
369
|
+
...emptyStructuredSummary(),
|
|
370
|
+
projectContext: 'A test project',
|
|
371
|
+
lastUpdated: '2025-02-04T10:00:00Z'
|
|
372
|
+
};
|
|
373
|
+
const result = renderSummaryToMarkdown(summary, 'test-project');
|
|
374
|
+
assert.ok(result.high.includes('A test project'));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('renders key decisions', () => {
|
|
378
|
+
const summary = {
|
|
379
|
+
...emptyStructuredSummary(),
|
|
380
|
+
keyDecisions: [{ date: '2025-02-04', decision: 'Use JWT', reason: 'Security' }],
|
|
381
|
+
lastUpdated: '2025-02-04T10:00:00Z'
|
|
382
|
+
};
|
|
383
|
+
const result = renderSummaryToMarkdown(summary, 'test-project');
|
|
384
|
+
assert.ok(result.high.includes('Use JWT'));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('renders recent work in medium priority', () => {
|
|
388
|
+
const summary = {
|
|
389
|
+
...emptyStructuredSummary(),
|
|
390
|
+
recentWork: [{ date: new Date().toISOString().slice(0, 10), summary: 'Fixed auth' }],
|
|
391
|
+
lastUpdated: new Date().toISOString()
|
|
392
|
+
};
|
|
393
|
+
const result = renderSummaryToMarkdown(summary, 'test-project');
|
|
394
|
+
assert.ok(result.medium.includes('Fixed auth'));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('returns only header for empty summary', () => {
|
|
398
|
+
const summary = emptyStructuredSummary();
|
|
399
|
+
const result = renderSummaryToMarkdown(summary, 'test-project');
|
|
400
|
+
// Empty summary still gets the markdown header
|
|
401
|
+
assert.ok(result.high.includes('Memory Summary'), 'Should have header');
|
|
402
|
+
assert.ok(!result.high.includes('## Key Decisions'), 'Should not have decisions section');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// withFileLock
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
describe('withFileLock', () => {
|
|
411
|
+
let tmpDir;
|
|
412
|
+
|
|
413
|
+
before(() => {
|
|
414
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'mneme-test-'));
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
after(() => {
|
|
418
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('acquires lock, runs fn, and returns result', () => {
|
|
422
|
+
const lockPath = join(tmpDir, 'test1.lock');
|
|
423
|
+
const result = withFileLock(lockPath, () => 42);
|
|
424
|
+
assert.equal(result, 42);
|
|
425
|
+
assert.ok(!existsSync(lockPath), 'Lock file should be cleaned up');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('cleans up lock even if fn throws', () => {
|
|
429
|
+
const lockPath = join(tmpDir, 'test2.lock');
|
|
430
|
+
assert.throws(() => {
|
|
431
|
+
withFileLock(lockPath, () => { throw new Error('boom'); });
|
|
432
|
+
}, /boom/);
|
|
433
|
+
assert.ok(!existsSync(lockPath), 'Lock file should be cleaned up after throw');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('returns undefined when lock is held by another', () => {
|
|
437
|
+
const lockPath = join(tmpDir, 'test3.lock');
|
|
438
|
+
// Simulate a held lock
|
|
439
|
+
writeFileSync(lockPath, 'other-pid');
|
|
440
|
+
const result = withFileLock(lockPath, () => 42, 60); // 60s stale threshold
|
|
441
|
+
assert.equal(result, undefined, 'Should return undefined when lock is held');
|
|
442
|
+
// Clean up
|
|
443
|
+
try { rmSync(lockPath); } catch {}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('breaks stale locks', () => {
|
|
447
|
+
const lockPath = join(tmpDir, 'test4.lock');
|
|
448
|
+
// Create a lock file and set mtime to the past
|
|
449
|
+
writeFileSync(lockPath, 'stale-pid');
|
|
450
|
+
const result = withFileLock(lockPath, () => 'won', 0); // 0s = always stale
|
|
451
|
+
assert.equal(result, 'won', 'Should break stale lock and run fn');
|
|
452
|
+
assert.ok(!existsSync(lockPath), 'Lock should be cleaned up');
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// loadConfig caching
|
|
458
|
+
// ============================================================================
|
|
459
|
+
|
|
460
|
+
describe('loadConfig', () => {
|
|
461
|
+
it('returns the same cached object on subsequent calls', () => {
|
|
462
|
+
const config1 = loadConfig();
|
|
463
|
+
const config2 = loadConfig();
|
|
464
|
+
assert.equal(config1, config2, 'Should return same reference (cached)');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('returns an object with expected default keys', () => {
|
|
468
|
+
const config = loadConfig();
|
|
469
|
+
assert.ok(config.maxLogEntriesBeforeSummarize > 0);
|
|
470
|
+
assert.ok(config.keepRecentEntries > 0);
|
|
471
|
+
assert.ok(typeof config.claudePath === 'string');
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// flushPendingLog
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
describe('flushPendingLog', () => {
|
|
480
|
+
let tmpDir;
|
|
481
|
+
let origCwd;
|
|
482
|
+
|
|
483
|
+
before(() => {
|
|
484
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'mneme-flush-'));
|
|
485
|
+
origCwd = process.cwd();
|
|
486
|
+
// Create the directory structure that ensureMemoryDirs expects
|
|
487
|
+
process.chdir(tmpDir);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
after(() => {
|
|
491
|
+
process.chdir(origCwd);
|
|
492
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('moves pending entries to main log', () => {
|
|
496
|
+
const paths = ensureMemoryDirs(tmpDir);
|
|
497
|
+
const pendingPath = paths.log.replace('.jsonl', '.pending.jsonl');
|
|
498
|
+
|
|
499
|
+
const entry1 = JSON.stringify({ ts: new Date().toISOString(), type: 'test', content: 'entry1' });
|
|
500
|
+
const entry2 = JSON.stringify({ ts: new Date().toISOString(), type: 'test', content: 'entry2' });
|
|
501
|
+
appendFileSync(pendingPath, entry1 + '\n');
|
|
502
|
+
appendFileSync(pendingPath, entry2 + '\n');
|
|
503
|
+
|
|
504
|
+
flushPendingLog(tmpDir, 0);
|
|
505
|
+
|
|
506
|
+
assert.ok(existsSync(paths.log), 'Main log should exist');
|
|
507
|
+
const logContent = readFileSync(paths.log, 'utf-8');
|
|
508
|
+
assert.ok(logContent.includes('entry1'), 'Main log should contain entry1');
|
|
509
|
+
assert.ok(logContent.includes('entry2'), 'Main log should contain entry2');
|
|
510
|
+
assert.ok(!existsSync(pendingPath), 'Pending file should be removed (renamed away)');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('respects throttle', () => {
|
|
514
|
+
const paths = ensureMemoryDirs(tmpDir);
|
|
515
|
+
const pendingPath = paths.log.replace('.jsonl', '.pending.jsonl');
|
|
516
|
+
const lastFlushPath = paths.log + '.lastflush';
|
|
517
|
+
|
|
518
|
+
// Write a recent flush timestamp
|
|
519
|
+
writeFileSync(lastFlushPath, Date.now().toString());
|
|
520
|
+
appendFileSync(pendingPath, JSON.stringify({ ts: new Date().toISOString(), type: 'test', content: 'throttled' }) + '\n');
|
|
521
|
+
|
|
522
|
+
const logBefore = existsSync(paths.log) ? readFileSync(paths.log, 'utf-8') : '';
|
|
523
|
+
flushPendingLog(tmpDir, 60000); // 60s throttle
|
|
524
|
+
const logAfter = existsSync(paths.log) ? readFileSync(paths.log, 'utf-8') : '';
|
|
525
|
+
|
|
526
|
+
assert.equal(logBefore, logAfter, 'Log should not change when throttled');
|
|
527
|
+
assert.ok(existsSync(pendingPath), 'Pending file should still exist');
|
|
528
|
+
|
|
529
|
+
// Clean up for other tests
|
|
530
|
+
rmSync(pendingPath, { force: true });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('handles missing pending file gracefully', () => {
|
|
534
|
+
// Should not throw
|
|
535
|
+
flushPendingLog(tmpDir, 0);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// calculateRecencyScore
|
|
541
|
+
// ============================================================================
|
|
542
|
+
|
|
543
|
+
describe('calculateRecencyScore', () => {
|
|
544
|
+
it('returns ~1.0 for timestamps just now', () => {
|
|
545
|
+
const score = calculateRecencyScore(new Date().toISOString());
|
|
546
|
+
assert.ok(score > 0.99, `Expected >0.99, got ${score}`);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('returns 0.5 at exactly one half-life', () => {
|
|
550
|
+
const oneHalfLifeAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
551
|
+
const score = calculateRecencyScore(oneHalfLifeAgo, 24);
|
|
552
|
+
assert.ok(Math.abs(score - 0.5) < 0.01, `Expected ~0.5, got ${score}`);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('returns 0.25 at two half-lives', () => {
|
|
556
|
+
const twoHalfLivesAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
557
|
+
const score = calculateRecencyScore(twoHalfLivesAgo, 24);
|
|
558
|
+
assert.ok(Math.abs(score - 0.25) < 0.01, `Expected ~0.25, got ${score}`);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('respects custom halfLifeHours', () => {
|
|
562
|
+
const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
|
|
563
|
+
const score = calculateRecencyScore(sixHoursAgo, 6);
|
|
564
|
+
assert.ok(Math.abs(score - 0.5) < 0.01, `Expected ~0.5 with 6h half-life, got ${score}`);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('approaches zero for very old timestamps', () => {
|
|
568
|
+
const yearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
|
569
|
+
const score = calculateRecencyScore(yearAgo, 24);
|
|
570
|
+
assert.ok(score < 0.001, `Expected near-zero for year-old entry, got ${score}`);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('returns >1 for future timestamps', () => {
|
|
574
|
+
const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
575
|
+
const score = calculateRecencyScore(future, 24);
|
|
576
|
+
assert.ok(score > 1.0, `Future timestamps should score >1, got ${score}`);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// ============================================================================
|
|
581
|
+
// calculateFileRelevanceScore
|
|
582
|
+
// ============================================================================
|
|
583
|
+
|
|
584
|
+
describe('calculateFileRelevanceScore', () => {
|
|
585
|
+
it('returns 0.5 for entries without file paths', () => {
|
|
586
|
+
const score = calculateFileRelevanceScore({ content: 'no files here' }, '/home/user/project');
|
|
587
|
+
assert.equal(score, 0.5);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('scores 1.0 for relative paths (assumed in-project)', () => {
|
|
591
|
+
const score = calculateFileRelevanceScore(
|
|
592
|
+
{ content: 'Updated src/auth.ts and lib/utils.mjs' },
|
|
593
|
+
'/home/user/project'
|
|
594
|
+
);
|
|
595
|
+
assert.equal(score, 1.0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('treats absolute paths as no-file (extractFilePaths ignores leading /)', () => {
|
|
599
|
+
// extractFilePaths regex only matches relative-style paths
|
|
600
|
+
const score = calculateFileRelevanceScore(
|
|
601
|
+
{ content: 'Changed /home/user/project/src/main.ts' },
|
|
602
|
+
'/home/user/project'
|
|
603
|
+
);
|
|
604
|
+
assert.equal(score, 0.5, 'Absolute paths not extracted → neutral score');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('only sees the relative path in a mix with absolute', () => {
|
|
608
|
+
// /etc/hosts is ignored by extractFilePaths, only src/auth.ts is matched
|
|
609
|
+
const score = calculateFileRelevanceScore(
|
|
610
|
+
{ content: 'Compared src/auth.ts with /etc/hosts' },
|
|
611
|
+
'/home/user/project'
|
|
612
|
+
);
|
|
613
|
+
assert.equal(score, 1.0, 'Only relative src/auth.ts is extracted');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('recognizes common project directory prefixes', () => {
|
|
617
|
+
const score = calculateFileRelevanceScore(
|
|
618
|
+
{ content: 'Modified components/Header.tsx' },
|
|
619
|
+
'/home/user/project'
|
|
620
|
+
);
|
|
621
|
+
assert.equal(score, 1.0);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ============================================================================
|
|
626
|
+
// calculateTypePriorityScore
|
|
627
|
+
// ============================================================================
|
|
628
|
+
|
|
629
|
+
describe('calculateTypePriorityScore', () => {
|
|
630
|
+
const typePriorities = {
|
|
631
|
+
commit: 1.0,
|
|
632
|
+
task: 0.9,
|
|
633
|
+
agent: 0.8,
|
|
634
|
+
prompt: 0.5,
|
|
635
|
+
response: 0.3,
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
it('returns the mapped priority for known types', () => {
|
|
639
|
+
assert.equal(calculateTypePriorityScore({ type: 'commit' }, typePriorities), 1.0);
|
|
640
|
+
assert.equal(calculateTypePriorityScore({ type: 'prompt' }, typePriorities), 0.5);
|
|
641
|
+
assert.equal(calculateTypePriorityScore({ type: 'response' }, typePriorities), 0.3);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('returns 0.5 for unknown types', () => {
|
|
645
|
+
assert.equal(calculateTypePriorityScore({ type: 'custom' }, typePriorities), 0.5);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('returns 0.5 for missing type field', () => {
|
|
649
|
+
assert.equal(calculateTypePriorityScore({}, typePriorities), 0.5);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('applies outcome multiplier for task entries', () => {
|
|
653
|
+
const outcomePriority = { completed: 1.0, abandoned: 0.3 };
|
|
654
|
+
const entry = { type: 'task', outcome: 'abandoned' };
|
|
655
|
+
const score = calculateTypePriorityScore(entry, typePriorities, outcomePriority);
|
|
656
|
+
assert.ok(Math.abs(score - 0.9 * 0.3) < 0.001, `Expected ${0.9 * 0.3}, got ${score}`);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('ignores outcome for non-task entries', () => {
|
|
660
|
+
const outcomePriority = { completed: 1.0, abandoned: 0.3 };
|
|
661
|
+
const entry = { type: 'commit', outcome: 'abandoned' };
|
|
662
|
+
const score = calculateTypePriorityScore(entry, typePriorities, outcomePriority);
|
|
663
|
+
assert.equal(score, 1.0);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('uses multiplier 1.0 for unknown outcomes', () => {
|
|
667
|
+
const outcomePriority = { completed: 1.0 };
|
|
668
|
+
const entry = { type: 'task', outcome: 'unknown_outcome' };
|
|
669
|
+
const score = calculateTypePriorityScore(entry, typePriorities, outcomePriority);
|
|
670
|
+
assert.equal(score, 0.9);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('skips outcome when outcomePriority is null', () => {
|
|
674
|
+
const entry = { type: 'task', outcome: 'abandoned' };
|
|
675
|
+
const score = calculateTypePriorityScore(entry, typePriorities, null);
|
|
676
|
+
assert.equal(score, 0.9);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// ============================================================================
|
|
681
|
+
// calculateEntityRelevanceScore
|
|
682
|
+
// ============================================================================
|
|
683
|
+
|
|
684
|
+
describe('calculateEntityRelevanceScore', () => {
|
|
685
|
+
it('returns 0.5 for empty entity index', () => {
|
|
686
|
+
assert.equal(calculateEntityRelevanceScore({ content: 'anything' }, {}), 0.5);
|
|
687
|
+
assert.equal(calculateEntityRelevanceScore({ content: 'anything' }, null), 0.5);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('returns 0.5 for entry with no extractable entities', () => {
|
|
691
|
+
const index = { files: { 'src/auth.ts': { mentions: 5, lastSeen: new Date().toISOString() } } };
|
|
692
|
+
const score = calculateEntityRelevanceScore({ content: 'nothing relevant' }, index);
|
|
693
|
+
assert.equal(score, 0.5);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('scores high for recently-seen, frequently-mentioned entities', () => {
|
|
697
|
+
const now = new Date().toISOString();
|
|
698
|
+
const index = {
|
|
699
|
+
files: { 'src/auth.ts': { mentions: 10, lastSeen: now } }
|
|
700
|
+
};
|
|
701
|
+
const entry = { content: 'Updated src/auth.ts' };
|
|
702
|
+
const score = calculateEntityRelevanceScore(entry, index);
|
|
703
|
+
// recency ~1.0, frequency = min(10/10, 1) = 1.0 → 0.6*1 + 0.4*1 = 1.0
|
|
704
|
+
assert.ok(score > 0.9, `Expected high score for hot entity, got ${score}`);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('scores lower for old entities', () => {
|
|
708
|
+
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
709
|
+
const index = {
|
|
710
|
+
files: { 'src/auth.ts': { mentions: 10, lastSeen: monthAgo } }
|
|
711
|
+
};
|
|
712
|
+
const entry = { content: 'Updated src/auth.ts' };
|
|
713
|
+
const score = calculateEntityRelevanceScore(entry, index);
|
|
714
|
+
// recency ≈ 0 (30 days >> 24h half-life), frequency = 1.0 → ~0.4*1 = 0.4
|
|
715
|
+
assert.ok(score < 0.5, `Expected low score for old entity, got ${score}`);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('scores lower for infrequent entities', () => {
|
|
719
|
+
const now = new Date().toISOString();
|
|
720
|
+
const index = {
|
|
721
|
+
files: { 'src/auth.ts': { mentions: 1, lastSeen: now } }
|
|
722
|
+
};
|
|
723
|
+
const entry = { content: 'Updated src/auth.ts' };
|
|
724
|
+
const score = calculateEntityRelevanceScore(entry, index);
|
|
725
|
+
// recency ~1.0, frequency = 1/10 = 0.1 → 0.6*1 + 0.4*0.1 = 0.64
|
|
726
|
+
assert.ok(score > 0.6 && score < 0.7, `Expected ~0.64 for infrequent entity, got ${score}`);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('averages across multiple entities', () => {
|
|
730
|
+
const now = new Date().toISOString();
|
|
731
|
+
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
732
|
+
const index = {
|
|
733
|
+
files: {
|
|
734
|
+
'src/auth.ts': { mentions: 10, lastSeen: now },
|
|
735
|
+
'src/db.ts': { mentions: 10, lastSeen: old },
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
const entry = { content: 'Updated src/auth.ts and src/db.ts' };
|
|
739
|
+
const score = calculateEntityRelevanceScore(entry, index);
|
|
740
|
+
// One hot (~1.0) + one cold (~0.4) → average ~0.7
|
|
741
|
+
assert.ok(score > 0.5 && score < 0.85, `Expected blended score, got ${score}`);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('ignores entities not in the index', () => {
|
|
745
|
+
const now = new Date().toISOString();
|
|
746
|
+
const index = {
|
|
747
|
+
files: { 'src/auth.ts': { mentions: 10, lastSeen: now } }
|
|
748
|
+
};
|
|
749
|
+
const entry = { content: 'Updated src/auth.ts and src/unknown.ts' };
|
|
750
|
+
const score = calculateEntityRelevanceScore(entry, index);
|
|
751
|
+
// Only src/auth.ts is in index, so score based on that alone
|
|
752
|
+
assert.ok(score > 0.9, `Expected high score (unknown ignored), got ${score}`);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// ============================================================================
|
|
757
|
+
// pruneEntityIndex
|
|
758
|
+
// ============================================================================
|
|
759
|
+
|
|
760
|
+
describe('pruneEntityIndex', () => {
|
|
761
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
762
|
+
|
|
763
|
+
function daysAgo(n) {
|
|
764
|
+
return new Date(Date.now() - n * DAY_MS).toISOString();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function makeEntity(lastSeen, mentions = 3) {
|
|
768
|
+
return { mentions, lastSeen, contexts: [] };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
it('removes entities older than maxAgeDays', () => {
|
|
772
|
+
const index = {
|
|
773
|
+
files: {
|
|
774
|
+
'src/old.ts': makeEntity(daysAgo(60)),
|
|
775
|
+
'src/fresh.ts': makeEntity(daysAgo(5)),
|
|
776
|
+
},
|
|
777
|
+
functions: {},
|
|
778
|
+
errors: {},
|
|
779
|
+
packages: {},
|
|
780
|
+
};
|
|
781
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
782
|
+
assert.equal(index.files['src/old.ts'], undefined, 'Old entity should be pruned');
|
|
783
|
+
assert.ok(index.files['src/fresh.ts'], 'Fresh entity should remain');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('keeps entities within maxAgeDays', () => {
|
|
787
|
+
const index = {
|
|
788
|
+
files: {
|
|
789
|
+
'src/recent.ts': makeEntity(daysAgo(10)),
|
|
790
|
+
'src/borderline.ts': makeEntity(daysAgo(29)),
|
|
791
|
+
},
|
|
792
|
+
functions: {},
|
|
793
|
+
errors: {},
|
|
794
|
+
packages: {},
|
|
795
|
+
};
|
|
796
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
797
|
+
assert.ok(index.files['src/recent.ts'], '10-day-old entity should survive 30-day cutoff');
|
|
798
|
+
assert.ok(index.files['src/borderline.ts'], '29-day-old entity should survive 30-day cutoff');
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('prunes across all categories', () => {
|
|
802
|
+
const index = {
|
|
803
|
+
files: { 'src/old.ts': makeEntity(daysAgo(60)) },
|
|
804
|
+
functions: { 'handleLogin': makeEntity(daysAgo(60)) },
|
|
805
|
+
errors: { 'TypeError': makeEntity(daysAgo(60)) },
|
|
806
|
+
packages: { 'lodash': makeEntity(daysAgo(60)) },
|
|
807
|
+
};
|
|
808
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
809
|
+
assert.equal(Object.keys(index.files).length, 0);
|
|
810
|
+
assert.equal(Object.keys(index.functions).length, 0);
|
|
811
|
+
assert.equal(Object.keys(index.errors).length, 0);
|
|
812
|
+
assert.equal(Object.keys(index.packages).length, 0);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('removes entities with no lastSeen', () => {
|
|
816
|
+
const index = {
|
|
817
|
+
files: { 'src/ghost.ts': { mentions: 5, lastSeen: null, contexts: [] } },
|
|
818
|
+
functions: {},
|
|
819
|
+
errors: {},
|
|
820
|
+
packages: {},
|
|
821
|
+
};
|
|
822
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
823
|
+
assert.equal(index.files['src/ghost.ts'], undefined, 'Entity with null lastSeen should be pruned');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('respects custom maxAgeDays', () => {
|
|
827
|
+
const index = {
|
|
828
|
+
files: {
|
|
829
|
+
'src/a.ts': makeEntity(daysAgo(8)),
|
|
830
|
+
'src/b.ts': makeEntity(daysAgo(3)),
|
|
831
|
+
},
|
|
832
|
+
functions: {},
|
|
833
|
+
errors: {},
|
|
834
|
+
packages: {},
|
|
835
|
+
};
|
|
836
|
+
pruneEntityIndex(index, { maxAgeDays: 7 });
|
|
837
|
+
assert.equal(index.files['src/a.ts'], undefined, '8-day-old should be pruned with 7-day cutoff');
|
|
838
|
+
assert.ok(index.files['src/b.ts'], '3-day-old should survive 7-day cutoff');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('is disabled when maxAgeDays is 0', () => {
|
|
842
|
+
const index = {
|
|
843
|
+
files: { 'src/ancient.ts': makeEntity(daysAgo(365)) },
|
|
844
|
+
functions: {},
|
|
845
|
+
errors: {},
|
|
846
|
+
packages: {},
|
|
847
|
+
};
|
|
848
|
+
pruneEntityIndex(index, { maxAgeDays: 0 });
|
|
849
|
+
assert.ok(index.files['src/ancient.ts'], 'Nothing should be pruned when disabled');
|
|
850
|
+
assert.equal(index.lastPruned, undefined, 'lastPruned should not be set when disabled');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('is disabled when maxAgeDays is negative', () => {
|
|
854
|
+
const index = {
|
|
855
|
+
files: { 'src/old.ts': makeEntity(daysAgo(365)) },
|
|
856
|
+
functions: {},
|
|
857
|
+
errors: {},
|
|
858
|
+
packages: {},
|
|
859
|
+
};
|
|
860
|
+
pruneEntityIndex(index, { maxAgeDays: -1 });
|
|
861
|
+
assert.ok(index.files['src/old.ts'], 'Nothing should be pruned with negative maxAgeDays');
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('sets lastPruned timestamp after pruning', () => {
|
|
865
|
+
const index = {
|
|
866
|
+
files: {},
|
|
867
|
+
functions: {},
|
|
868
|
+
errors: {},
|
|
869
|
+
packages: {},
|
|
870
|
+
};
|
|
871
|
+
const before = Date.now();
|
|
872
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
873
|
+
const after = Date.now();
|
|
874
|
+
assert.ok(index.lastPruned, 'lastPruned should be set');
|
|
875
|
+
const prunedTime = new Date(index.lastPruned).getTime();
|
|
876
|
+
assert.ok(prunedTime >= before && prunedTime <= after, 'lastPruned should be roughly now');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('skips pruning if already pruned within 24 hours', () => {
|
|
880
|
+
const index = {
|
|
881
|
+
files: { 'src/old.ts': makeEntity(daysAgo(60)) },
|
|
882
|
+
functions: {},
|
|
883
|
+
errors: {},
|
|
884
|
+
packages: {},
|
|
885
|
+
lastPruned: new Date().toISOString(), // just pruned
|
|
886
|
+
};
|
|
887
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
888
|
+
assert.ok(index.files['src/old.ts'], 'Old entity should survive — daily guard prevents pruning');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('prunes again after 24 hours have passed', () => {
|
|
892
|
+
const index = {
|
|
893
|
+
files: { 'src/old.ts': makeEntity(daysAgo(60)) },
|
|
894
|
+
functions: {},
|
|
895
|
+
errors: {},
|
|
896
|
+
packages: {},
|
|
897
|
+
lastPruned: daysAgo(2), // pruned 2 days ago
|
|
898
|
+
};
|
|
899
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
900
|
+
assert.equal(index.files['src/old.ts'], undefined, 'Old entity should be pruned after guard expires');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('uses default maxAgeDays of 30 when not configured', () => {
|
|
904
|
+
const index = {
|
|
905
|
+
files: {
|
|
906
|
+
'src/old.ts': makeEntity(daysAgo(31)),
|
|
907
|
+
'src/fresh.ts': makeEntity(daysAgo(29)),
|
|
908
|
+
},
|
|
909
|
+
functions: {},
|
|
910
|
+
errors: {},
|
|
911
|
+
packages: {},
|
|
912
|
+
};
|
|
913
|
+
pruneEntityIndex(index); // no config
|
|
914
|
+
assert.equal(index.files['src/old.ts'], undefined, '31-day-old should be pruned by default');
|
|
915
|
+
assert.ok(index.files['src/fresh.ts'], '29-day-old should survive default cutoff');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('does not touch metadata keys stored alongside categories', () => {
|
|
919
|
+
const index = {
|
|
920
|
+
files: { 'src/fresh.ts': makeEntity(daysAgo(1)) },
|
|
921
|
+
functions: {},
|
|
922
|
+
errors: {},
|
|
923
|
+
packages: {},
|
|
924
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
925
|
+
};
|
|
926
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
927
|
+
assert.equal(index.lastUpdated, '2025-01-01T00:00:00Z', 'lastUpdated metadata should be untouched');
|
|
928
|
+
assert.ok(index.lastPruned, 'lastPruned should be set');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('handles missing categories gracefully', () => {
|
|
932
|
+
const index = { files: { 'src/old.ts': makeEntity(daysAgo(60)) } };
|
|
933
|
+
// No functions/errors/packages keys at all
|
|
934
|
+
pruneEntityIndex(index, { maxAgeDays: 30 });
|
|
935
|
+
assert.equal(index.files['src/old.ts'], undefined, 'Should prune even with missing categories');
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// ============================================================================
|
|
940
|
+
// extractFilePaths
|
|
941
|
+
// ============================================================================
|
|
942
|
+
|
|
943
|
+
describe('extractFilePaths', () => {
|
|
944
|
+
const e = (content) => ({ content });
|
|
945
|
+
|
|
946
|
+
it('extracts relative paths with directories', () => {
|
|
947
|
+
const paths = extractFilePaths(e('Updated src/auth.ts and lib/utils.mjs'));
|
|
948
|
+
assert.ok(paths.includes('src/auth.ts'));
|
|
949
|
+
assert.ok(paths.includes('lib/utils.mjs'));
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('extracts deeply nested paths', () => {
|
|
953
|
+
const paths = extractFilePaths(e('Changed src/components/auth/Login.tsx'));
|
|
954
|
+
assert.ok(paths.includes('src/components/auth/Login.tsx'));
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('extracts backtick-wrapped files', () => {
|
|
958
|
+
const paths = extractFilePaths(e('Check `config.json` and `src/index.ts` for details'));
|
|
959
|
+
assert.ok(paths.includes('config.json'));
|
|
960
|
+
assert.ok(paths.includes('src/index.ts'));
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('extracts files after keywords', () => {
|
|
964
|
+
const paths = extractFilePaths(e('Updated file utils.mjs and created test.ts'));
|
|
965
|
+
assert.ok(paths.includes('utils.mjs'), `Expected utils.mjs in ${JSON.stringify(paths)}`);
|
|
966
|
+
assert.ok(paths.includes('test.ts'), `Expected test.ts in ${JSON.stringify(paths)}`);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('extracts standalone filenames with common extensions', () => {
|
|
970
|
+
const paths = extractFilePaths(e('Look at package.json for the config'));
|
|
971
|
+
assert.ok(paths.includes('package.json'), `Expected package.json in ${JSON.stringify(paths)}`);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it('deduplicates paths', () => {
|
|
975
|
+
const paths = extractFilePaths(e('Updated src/auth.ts then reviewed src/auth.ts again'));
|
|
976
|
+
assert.equal(paths.filter(p => p === 'src/auth.ts').length, 1);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('ignores paths with unrecognized extensions', () => {
|
|
980
|
+
const paths = extractFilePaths(e('Opened src/data.xyz'));
|
|
981
|
+
assert.ok(!paths.includes('src/data.xyz'));
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('ignores very long paths (>=100 chars)', () => {
|
|
985
|
+
const longPath = 'src/' + 'a'.repeat(93) + '.ts'; // 4 + 93 + 3 = 100 chars
|
|
986
|
+
const paths = extractFilePaths(e(`Changed ${longPath}`));
|
|
987
|
+
assert.equal(paths.length, 0);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('returns empty for content with no file references', () => {
|
|
991
|
+
const paths = extractFilePaths(e('Just a normal sentence without files'));
|
|
992
|
+
assert.equal(paths.length, 0);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('reads from subject when content is empty', () => {
|
|
996
|
+
const paths = extractFilePaths({ subject: 'Fix bug in src/main.ts' });
|
|
997
|
+
assert.ok(paths.includes('src/main.ts'));
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('respects custom fileExtensions config', () => {
|
|
1001
|
+
const paths = extractFilePaths(e('Changed src/style.css and src/app.tsx'), {
|
|
1002
|
+
fileExtensions: ['tsx'],
|
|
1003
|
+
});
|
|
1004
|
+
assert.ok(paths.includes('src/app.tsx'));
|
|
1005
|
+
assert.ok(!paths.includes('src/style.css'), 'css not in custom extension list');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('filters out version-number false positives', () => {
|
|
1009
|
+
// isFileFalsePositive catches "1.0.0.js"-like patterns
|
|
1010
|
+
const paths = extractFilePaths(e('Version 2.0.js is out'));
|
|
1011
|
+
assert.ok(!paths.some(p => p.includes('2.0')), `Version-like path should be filtered: ${JSON.stringify(paths)}`);
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// ============================================================================
|
|
1016
|
+
// isFileFalsePositive
|
|
1017
|
+
// ============================================================================
|
|
1018
|
+
|
|
1019
|
+
describe('isFileFalsePositive', () => {
|
|
1020
|
+
it('rejects version-number patterns', () => {
|
|
1021
|
+
assert.equal(isFileFalsePositive('1.0.0.js'), true);
|
|
1022
|
+
assert.equal(isFileFalsePositive('2.3.js'), true);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('rejects URLs', () => {
|
|
1026
|
+
assert.equal(isFileFalsePositive('http://example.com'), true);
|
|
1027
|
+
assert.equal(isFileFalsePositive('https://foo.bar'), true);
|
|
1028
|
+
assert.equal(isFileFalsePositive('www.example.com'), true);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('rejects error-message false positives', () => {
|
|
1032
|
+
assert.equal(isFileFalsePositive('read.property'), true);
|
|
1033
|
+
assert.equal(isFileFalsePositive('of.undefined'), true);
|
|
1034
|
+
assert.equal(isFileFalsePositive('some.property.access'), true);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it('accepts valid file paths', () => {
|
|
1038
|
+
assert.equal(isFileFalsePositive('src/auth.ts'), false);
|
|
1039
|
+
assert.equal(isFileFalsePositive('utils.mjs'), false);
|
|
1040
|
+
assert.equal(isFileFalsePositive('package.json'), false);
|
|
1041
|
+
assert.equal(isFileFalsePositive('README.md'), false);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('is case-insensitive for false positive words', () => {
|
|
1045
|
+
assert.equal(isFileFalsePositive('Read.Property'), true);
|
|
1046
|
+
assert.equal(isFileFalsePositive('OF.UNDEFINED'), true);
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// ============================================================================
|
|
1051
|
+
// extractFunctionNames
|
|
1052
|
+
// ============================================================================
|
|
1053
|
+
|
|
1054
|
+
describe('extractFunctionNames', () => {
|
|
1055
|
+
const e = (content) => ({ content });
|
|
1056
|
+
|
|
1057
|
+
it('extracts standard function declarations', () => {
|
|
1058
|
+
const fns = extractFunctionNames(e('function handleLogin() { ... }'));
|
|
1059
|
+
assert.ok(fns.includes('handleLogin'), `Expected handleLogin in ${JSON.stringify(fns)}`);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('extracts arrow function assignments', () => {
|
|
1063
|
+
const fns = extractFunctionNames(e('const processData = (items) => { ... }'));
|
|
1064
|
+
assert.ok(fns.includes('processData'), `Expected processData in ${JSON.stringify(fns)}`);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('extracts backtick-wrapped function calls', () => {
|
|
1068
|
+
const fns = extractFunctionNames(e('Call `handleLogin()` to start'));
|
|
1069
|
+
assert.ok(fns.includes('handleLogin'), `Expected handleLogin in ${JSON.stringify(fns)}`);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('extracts "method/function X" pattern with camelCase', () => {
|
|
1073
|
+
// Pattern requires keyword BEFORE the name: "method handleLogin", not "handleLogin function"
|
|
1074
|
+
const fns = extractFunctionNames(e('the method handleLogin is broken'));
|
|
1075
|
+
assert.ok(fns.includes('handleLogin'), `Expected handleLogin in ${JSON.stringify(fns)}`);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('extracts Python def statements', () => {
|
|
1079
|
+
const fns = extractFunctionNames(e('def handle_request(req):'));
|
|
1080
|
+
assert.ok(fns.includes('handle_request'), `Expected handle_request in ${JSON.stringify(fns)}`);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('excludes JavaScript keywords', () => {
|
|
1084
|
+
const fns = extractFunctionNames(e('function if() {} function return() {}'));
|
|
1085
|
+
assert.ok(!fns.includes('if'));
|
|
1086
|
+
assert.ok(!fns.includes('return'));
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('excludes built-in objects and common methods', () => {
|
|
1090
|
+
const fns = extractFunctionNames(e('`console()` and `Array()` and `forEach()`'));
|
|
1091
|
+
assert.ok(!fns.includes('console'));
|
|
1092
|
+
assert.ok(!fns.includes('Array'));
|
|
1093
|
+
assert.ok(!fns.includes('forEach'));
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('requires mixed case or underscore for short names', () => {
|
|
1097
|
+
// "foo" is all-lowercase, 3 chars, no underscore → excluded
|
|
1098
|
+
const fns = extractFunctionNames(e('function foo() {} function fooBar() {}'));
|
|
1099
|
+
assert.ok(!fns.includes('foo'), 'Short all-lowercase name should be excluded');
|
|
1100
|
+
assert.ok(fns.includes('fooBar'), 'camelCase name should be included');
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('allows longer all-lowercase names (>=6 chars)', () => {
|
|
1104
|
+
const fns = extractFunctionNames(e('function foobar() {} function bazqux() {}'));
|
|
1105
|
+
// 6-char all-lowercase passes the length >= 6 check
|
|
1106
|
+
assert.ok(fns.includes('foobar'), `Expected foobar in ${JSON.stringify(fns)}`);
|
|
1107
|
+
assert.ok(fns.includes('bazqux'), `Expected bazqux in ${JSON.stringify(fns)}`);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it('returns empty for content with no function references', () => {
|
|
1111
|
+
const fns = extractFunctionNames(e('Just a normal sentence'));
|
|
1112
|
+
assert.equal(fns.length, 0);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('deduplicates function names', () => {
|
|
1116
|
+
const fns = extractFunctionNames(e('function handleLogin() {} called `handleLogin()`'));
|
|
1117
|
+
assert.equal(fns.filter(f => f === 'handleLogin').length, 1);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('respects minEntityLength from config', () => {
|
|
1121
|
+
const fns = extractFunctionNames(e('function handleLogin() {}'), { minEntityLength: 20 });
|
|
1122
|
+
assert.equal(fns.length, 0, 'handleLogin is <20 chars');
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// ============================================================================
|
|
1127
|
+
// extractErrorMessages
|
|
1128
|
+
// ============================================================================
|
|
1129
|
+
|
|
1130
|
+
describe('extractErrorMessages', () => {
|
|
1131
|
+
const e = (content) => ({ content });
|
|
1132
|
+
|
|
1133
|
+
it('extracts TypeError', () => {
|
|
1134
|
+
const errors = extractErrorMessages(e('TypeError: Cannot read properties of null'));
|
|
1135
|
+
assert.ok(errors.some(e => e.includes('TypeError')), `Expected TypeError in ${JSON.stringify(errors)}`);
|
|
1136
|
+
assert.ok(errors.some(e => e.includes('Cannot read')));
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it('extracts ReferenceError', () => {
|
|
1140
|
+
const errors = extractErrorMessages(e('ReferenceError: foo is not defined'));
|
|
1141
|
+
assert.ok(errors.some(e => e.includes('ReferenceError')));
|
|
1142
|
+
assert.ok(errors.some(e => e.includes('foo is not defined')));
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it('extracts SyntaxError', () => {
|
|
1146
|
+
const errors = extractErrorMessages(e('SyntaxError: Unexpected token }'));
|
|
1147
|
+
assert.ok(errors.some(e => e.includes('SyntaxError')));
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it('extracts plain Error', () => {
|
|
1151
|
+
const errors = extractErrorMessages(e('Error: ENOENT no such file or directory'));
|
|
1152
|
+
assert.ok(errors.some(e => e.includes('ENOENT')));
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('extracts "error:" prefix (case insensitive)', () => {
|
|
1156
|
+
const errors = extractErrorMessages(e('error: connection refused on port 5432'));
|
|
1157
|
+
assert.ok(errors.some(e => e.includes('connection refused')), `Expected match in ${JSON.stringify(errors)}`);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('extracts "failed:" pattern', () => {
|
|
1161
|
+
const errors = extractErrorMessages(e('failed: to compile module src/main.ts'));
|
|
1162
|
+
assert.ok(errors.some(e => e.includes('compile module')), `Expected match in ${JSON.stringify(errors)}`);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('extracts stack trace first lines', () => {
|
|
1166
|
+
const errors = extractErrorMessages(e(' at Module._compile (node:internal/modules/cjs/loader:1234:56)'));
|
|
1167
|
+
assert.ok(errors.length > 0, 'Should extract stack trace line');
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it('caps error messages at 100 chars', () => {
|
|
1171
|
+
const longMsg = 'TypeError: ' + 'x'.repeat(200);
|
|
1172
|
+
const errors = extractErrorMessages(e(longMsg));
|
|
1173
|
+
for (const err of errors) {
|
|
1174
|
+
assert.ok(err.length <= 100, `Error should be <=100 chars, got ${err.length}`);
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('returns empty for content with no errors', () => {
|
|
1179
|
+
const errors = extractErrorMessages(e('Everything worked perfectly'));
|
|
1180
|
+
assert.equal(errors.length, 0);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('deduplicates identical error messages', () => {
|
|
1184
|
+
const errors = extractErrorMessages(e('TypeError: foo bar\nTypeError: foo bar'));
|
|
1185
|
+
assert.equal(errors.filter(e => e.includes('foo bar')).length, 1);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('ignores too-short error messages', () => {
|
|
1189
|
+
// "error: ab" — message "ab" is only 2 chars, but the pattern requires >=5 chars
|
|
1190
|
+
const errors = extractErrorMessages(e('error: ab'));
|
|
1191
|
+
assert.equal(errors.length, 0, 'Very short error messages should be ignored');
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// ============================================================================
|
|
1196
|
+
// extractPackageNames
|
|
1197
|
+
// ============================================================================
|
|
1198
|
+
|
|
1199
|
+
describe('extractPackageNames', () => {
|
|
1200
|
+
const e = (content) => ({ content });
|
|
1201
|
+
|
|
1202
|
+
it('extracts from npm install command', () => {
|
|
1203
|
+
const pkgs = extractPackageNames(e('npm install express lodash'));
|
|
1204
|
+
assert.ok(pkgs.includes('express'), `Expected express in ${JSON.stringify(pkgs)}`);
|
|
1205
|
+
assert.ok(pkgs.includes('lodash'), `Expected lodash in ${JSON.stringify(pkgs)}`);
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('extracts from yarn add', () => {
|
|
1209
|
+
const pkgs = extractPackageNames(e('yarn add react react-dom'));
|
|
1210
|
+
assert.ok(pkgs.includes('react'));
|
|
1211
|
+
assert.ok(pkgs.includes('react-dom'));
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it('extracts scoped packages', () => {
|
|
1215
|
+
const pkgs = extractPackageNames(e("import sdk from '@anthropic-ai/sdk'"));
|
|
1216
|
+
assert.ok(pkgs.includes('@anthropic-ai/sdk'), `Expected scoped pkg in ${JSON.stringify(pkgs)}`);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it('extracts from ES import statements', () => {
|
|
1220
|
+
const pkgs = extractPackageNames(e("import express from 'express'"));
|
|
1221
|
+
assert.ok(pkgs.includes('express'));
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('extracts from require calls', () => {
|
|
1225
|
+
const pkgs = extractPackageNames(e("const z = require('zod')"));
|
|
1226
|
+
assert.ok(pkgs.includes('zod'));
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('extracts from pip install', () => {
|
|
1230
|
+
const pkgs = extractPackageNames(e('pip install requests'));
|
|
1231
|
+
assert.ok(pkgs.includes('requests'));
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it('strips version specifiers without dots', () => {
|
|
1235
|
+
const pkgs = extractPackageNames(e('npm install express@5 lodash@4'));
|
|
1236
|
+
assert.ok(pkgs.includes('express'), 'Should strip @5');
|
|
1237
|
+
assert.ok(pkgs.includes('lodash'), 'Should strip @4');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('strips dotted version specifiers from install commands', () => {
|
|
1241
|
+
const pkgs = extractPackageNames(e('npm install express@^5.0.0 lodash@4.17.21'));
|
|
1242
|
+
assert.ok(pkgs.includes('express'), 'Should extract express');
|
|
1243
|
+
assert.ok(pkgs.includes('lodash'), 'Should extract lodash');
|
|
1244
|
+
assert.ok(!pkgs.some(p => p.includes('@')), 'Version specifiers should be stripped');
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('ignores npm flags', () => {
|
|
1248
|
+
const pkgs = extractPackageNames(e('npm install --save-dev jest -D typescript'));
|
|
1249
|
+
assert.ok(!pkgs.some(p => p.startsWith('-')));
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
it('excludes Node.js built-in modules', () => {
|
|
1253
|
+
const pkgs = extractPackageNames(e("require('fs') and require('path') and require('crypto')"));
|
|
1254
|
+
assert.ok(!pkgs.includes('fs'));
|
|
1255
|
+
assert.ok(!pkgs.includes('path'));
|
|
1256
|
+
assert.ok(!pkgs.includes('crypto'));
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it('excludes common relative-import directory names', () => {
|
|
1260
|
+
const pkgs = extractPackageNames(e("import foo from 'utils'"));
|
|
1261
|
+
assert.ok(!pkgs.includes('utils'));
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it('excludes relative paths', () => {
|
|
1265
|
+
const pkgs = extractPackageNames(e("import x from './local'"));
|
|
1266
|
+
assert.ok(!pkgs.includes('./local'));
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it('deduplicates package names', () => {
|
|
1270
|
+
const pkgs = extractPackageNames(e("npm install express\nimport x from 'express'"));
|
|
1271
|
+
assert.equal(pkgs.filter(p => p === 'express').length, 1);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('returns empty for content with no packages', () => {
|
|
1275
|
+
const pkgs = extractPackageNames(e('No packages mentioned here'));
|
|
1276
|
+
assert.equal(pkgs.length, 0);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('extracts backtick-wrapped scoped packages', () => {
|
|
1280
|
+
const pkgs = extractPackageNames(e('Using `@anthropic-ai/sdk` for the API'));
|
|
1281
|
+
assert.ok(pkgs.includes('@anthropic-ai/sdk'));
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
// ============================================================================
|
|
1286
|
+
// isValidPackageName
|
|
1287
|
+
// ============================================================================
|
|
1288
|
+
|
|
1289
|
+
describe('isValidPackageName', () => {
|
|
1290
|
+
it('accepts normal package names', () => {
|
|
1291
|
+
assert.equal(isValidPackageName('express'), true);
|
|
1292
|
+
assert.equal(isValidPackageName('lodash'), true);
|
|
1293
|
+
assert.equal(isValidPackageName('react-dom'), true);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
it('accepts scoped packages', () => {
|
|
1297
|
+
assert.equal(isValidPackageName('@anthropic-ai/sdk'), true);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
it('rejects relative paths', () => {
|
|
1301
|
+
assert.equal(isValidPackageName('./local'), false);
|
|
1302
|
+
assert.equal(isValidPackageName('../parent'), false);
|
|
1303
|
+
assert.equal(isValidPackageName('/absolute'), false);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('rejects Node.js built-ins', () => {
|
|
1307
|
+
assert.equal(isValidPackageName('fs'), false);
|
|
1308
|
+
assert.equal(isValidPackageName('path'), false);
|
|
1309
|
+
assert.equal(isValidPackageName('crypto'), false);
|
|
1310
|
+
assert.equal(isValidPackageName('child_process'), false);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it('rejects common directory names', () => {
|
|
1314
|
+
assert.equal(isValidPackageName('src'), false);
|
|
1315
|
+
assert.equal(isValidPackageName('lib'), false);
|
|
1316
|
+
assert.equal(isValidPackageName('utils'), false);
|
|
1317
|
+
assert.equal(isValidPackageName('components'), false);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it('rejects names too short', () => {
|
|
1321
|
+
assert.equal(isValidPackageName('a'), false);
|
|
1322
|
+
assert.equal(isValidPackageName('', 2), false);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
it('rejects names too long (>=60)', () => {
|
|
1326
|
+
assert.equal(isValidPackageName('a'.repeat(60)), false);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('rejects null/undefined', () => {
|
|
1330
|
+
assert.equal(isValidPackageName(null), false);
|
|
1331
|
+
assert.equal(isValidPackageName(undefined), false);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('respects custom minLength', () => {
|
|
1335
|
+
assert.equal(isValidPackageName('ab', 3), false);
|
|
1336
|
+
assert.equal(isValidPackageName('abc', 3), true);
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// ============================================================================
|
|
1341
|
+
// stripMarkdown
|
|
1342
|
+
// ============================================================================
|
|
1343
|
+
|
|
1344
|
+
describe('stripMarkdown', () => {
|
|
1345
|
+
it('returns empty/null input unchanged', () => {
|
|
1346
|
+
assert.equal(stripMarkdown(''), '');
|
|
1347
|
+
assert.equal(stripMarkdown(null), null);
|
|
1348
|
+
assert.equal(stripMarkdown(undefined), undefined);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('passes plain text through unchanged', () => {
|
|
1352
|
+
assert.equal(stripMarkdown('Hello world'), 'Hello world');
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
it('strips bold markers', () => {
|
|
1356
|
+
assert.equal(stripMarkdown('This is **bold** text'), 'This is bold text');
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('strips italic markers', () => {
|
|
1360
|
+
assert.equal(stripMarkdown('This is *italic* text'), 'This is italic text');
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it('strips inline backticks', () => {
|
|
1364
|
+
assert.equal(
|
|
1365
|
+
stripMarkdown('Fixed `lib.rs`, `main.rs` formatting'),
|
|
1366
|
+
'Fixed lib.rs, main.rs formatting'
|
|
1367
|
+
);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it('strips code block fences', () => {
|
|
1371
|
+
assert.equal(
|
|
1372
|
+
stripMarkdown('```js\nconst x = 1;\n```'),
|
|
1373
|
+
'const x = 1;'
|
|
1374
|
+
);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it('strips headers', () => {
|
|
1378
|
+
assert.equal(stripMarkdown('## Summary\nSome text'), 'Summary\nSome text');
|
|
1379
|
+
assert.equal(stripMarkdown('### Details'), 'Details');
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('strips links, keeps text', () => {
|
|
1383
|
+
assert.equal(
|
|
1384
|
+
stripMarkdown('See [the docs](https://example.com) for details'),
|
|
1385
|
+
'See the docs for details'
|
|
1386
|
+
);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('strips images', () => {
|
|
1390
|
+
assert.equal(
|
|
1391
|
+
stripMarkdown('Here:  done'),
|
|
1392
|
+
'Here: done'
|
|
1393
|
+
);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it('strips block quotes', () => {
|
|
1397
|
+
assert.equal(stripMarkdown('> Note: something important'), 'Note: something important');
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('strips bullet markers', () => {
|
|
1401
|
+
assert.equal(stripMarkdown('- First item\n- Second item'), 'First item\nSecond item');
|
|
1402
|
+
assert.equal(stripMarkdown('* First\n* Second'), 'First\nSecond');
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('strips numbered list markers', () => {
|
|
1406
|
+
assert.equal(stripMarkdown('1. First\n2. Second'), 'First\nSecond');
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('strips checkboxes', () => {
|
|
1410
|
+
assert.equal(stripMarkdown('- [x] Done task\n- [ ] Todo task'), 'Done task\nTodo task');
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('strips strikethrough', () => {
|
|
1414
|
+
assert.equal(stripMarkdown('~~old text~~ new text'), 'old text new text');
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it('strips horizontal rules', () => {
|
|
1418
|
+
assert.equal(stripMarkdown('Above\n---\nBelow'), 'Above\n\nBelow');
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('strips emoji', () => {
|
|
1422
|
+
assert.equal(stripMarkdown('Thanks! 👋'), 'Thanks!');
|
|
1423
|
+
assert.equal(stripMarkdown('Great work 🎉🚀'), 'Great work');
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it('strips HTML tags', () => {
|
|
1427
|
+
assert.equal(stripMarkdown('text<br>more<details>hidden</details>'), 'textmorehidden');
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('collapses excessive blank lines', () => {
|
|
1431
|
+
assert.equal(stripMarkdown('A\n\n\n\nB'), 'A\n\nB');
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it('preserves hyphens in filenames and words', () => {
|
|
1435
|
+
assert.equal(stripMarkdown('Updated my-file.rs and stop-capture.mjs'), 'Updated my-file.rs and stop-capture.mjs');
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it('handles a realistic response', () => {
|
|
1439
|
+
const input = 'Fixed. Three long lines in `lib.rs`, `main.rs`, and a couple in `cache.rs`/`typosquat.rs` that `cargo fmt` wanted wrapped. Should be green now.';
|
|
1440
|
+
const expected = 'Fixed. Three long lines in lib.rs, main.rs, and a couple in cache.rs/typosquat.rs that cargo fmt wanted wrapped. Should be green now.';
|
|
1441
|
+
assert.equal(stripMarkdown(input), expected);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('handles a realistic agent response', () => {
|
|
1445
|
+
const input = 'Thanks, it was a good one! Enjoy the evening. 👋';
|
|
1446
|
+
const expected = 'Thanks, it was a good one! Enjoy the evening.';
|
|
1447
|
+
assert.equal(stripMarkdown(input), expected);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('handles combined formatting', () => {
|
|
1451
|
+
const input = '## Summary\n\n- **Fixed** the `auth` bug\n- Updated [docs](https://x.com)\n\n---\n\n> Note: needs review 🔍';
|
|
1452
|
+
const result = stripMarkdown(input);
|
|
1453
|
+
assert.ok(!result.includes('##'));
|
|
1454
|
+
assert.ok(!result.includes('**'));
|
|
1455
|
+
assert.ok(!result.includes('`'));
|
|
1456
|
+
assert.ok(!result.includes(']('));
|
|
1457
|
+
assert.ok(!result.includes('---'));
|
|
1458
|
+
assert.ok(!result.includes('>'));
|
|
1459
|
+
assert.ok(!result.includes('🔍'));
|
|
1460
|
+
assert.ok(result.includes('Fixed'));
|
|
1461
|
+
assert.ok(result.includes('auth'));
|
|
1462
|
+
assert.ok(result.includes('docs'));
|
|
1463
|
+
assert.ok(result.includes('needs review'));
|
|
1464
|
+
});
|
|
1465
|
+
});
|