@soleri/core 2.0.1 → 2.1.0
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/dist/brain/brain.d.ts +2 -49
- package/dist/brain/brain.d.ts.map +1 -1
- package/dist/brain/brain.js +1 -158
- package/dist/brain/brain.js.map +1 -1
- package/dist/brain/intelligence.d.ts +51 -0
- package/dist/brain/intelligence.d.ts.map +1 -0
- package/dist/brain/intelligence.js +666 -0
- package/dist/brain/intelligence.js.map +1 -0
- package/dist/brain/types.d.ts +165 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +2 -0
- package/dist/brain/types.js.map +1 -0
- package/dist/curator/curator.d.ts +28 -0
- package/dist/curator/curator.d.ts.map +1 -0
- package/dist/curator/curator.js +525 -0
- package/dist/curator/curator.js.map +1 -0
- package/dist/curator/types.d.ts +87 -0
- package/dist/curator/types.d.ts.map +1 -0
- package/dist/curator/types.js +3 -0
- package/dist/curator/types.js.map +1 -0
- package/dist/facades/types.d.ts +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/llm-client.d.ts +28 -0
- package/dist/llm/llm-client.d.ts.map +1 -0
- package/dist/llm/llm-client.js +226 -0
- package/dist/llm/llm-client.js.map +1 -0
- package/dist/runtime/core-ops.d.ts +17 -0
- package/dist/runtime/core-ops.d.ts.map +1 -0
- package/dist/runtime/core-ops.js +613 -0
- package/dist/runtime/core-ops.js.map +1 -0
- package/dist/runtime/domain-ops.d.ts +25 -0
- package/dist/runtime/domain-ops.d.ts.map +1 -0
- package/dist/runtime/domain-ops.js +130 -0
- package/dist/runtime/domain-ops.js.map +1 -0
- package/dist/runtime/runtime.d.ts +19 -0
- package/dist/runtime/runtime.d.ts.map +1 -0
- package/dist/runtime/runtime.js +66 -0
- package/dist/runtime/runtime.js.map +1 -0
- package/dist/runtime/types.d.ts +41 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/text/similarity.d.ts +8 -0
- package/dist/text/similarity.d.ts.map +1 -0
- package/dist/text/similarity.js +161 -0
- package/dist/text/similarity.js.map +1 -0
- package/package.json +6 -2
- package/src/__tests__/brain-intelligence.test.ts +623 -0
- package/src/__tests__/core-ops.test.ts +218 -0
- package/src/__tests__/curator.test.ts +574 -0
- package/src/__tests__/domain-ops.test.ts +160 -0
- package/src/__tests__/llm-client.test.ts +69 -0
- package/src/__tests__/runtime.test.ts +95 -0
- package/src/brain/brain.ts +27 -221
- package/src/brain/intelligence.ts +1061 -0
- package/src/brain/types.ts +176 -0
- package/src/curator/curator.ts +699 -0
- package/src/curator/types.ts +114 -0
- package/src/index.ts +55 -1
- package/src/llm/llm-client.ts +310 -0
- package/src/runtime/core-ops.ts +665 -0
- package/src/runtime/domain-ops.ts +144 -0
- package/src/runtime/runtime.ts +76 -0
- package/src/runtime/types.ts +39 -0
- package/src/text/similarity.ts +168 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Vault } from '../vault/vault.js';
|
|
3
|
+
import { Curator } from '../curator/curator.js';
|
|
4
|
+
import type { IntelligenceEntry } from '../intelligence/types.js';
|
|
5
|
+
|
|
6
|
+
function makeEntry(overrides: Partial<IntelligenceEntry> = {}): IntelligenceEntry {
|
|
7
|
+
return {
|
|
8
|
+
id: overrides.id ?? 'test-1',
|
|
9
|
+
type: overrides.type ?? 'pattern',
|
|
10
|
+
domain: overrides.domain ?? 'testing',
|
|
11
|
+
title: overrides.title ?? 'Test Pattern',
|
|
12
|
+
severity: overrides.severity ?? 'warning',
|
|
13
|
+
description: overrides.description ?? 'A test pattern for testing.',
|
|
14
|
+
tags: overrides.tags ?? ['testing'],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('Curator', () => {
|
|
19
|
+
let vault: Vault;
|
|
20
|
+
let curator: Curator;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vault = new Vault(':memory:');
|
|
24
|
+
curator = new Curator(vault);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vault.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ─── Constructor ──────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe('Constructor', () => {
|
|
34
|
+
it('should create curator tables on construction', () => {
|
|
35
|
+
const status = curator.getStatus();
|
|
36
|
+
expect(status.initialized).toBe(true);
|
|
37
|
+
expect(status.tables).toHaveProperty('entry_state');
|
|
38
|
+
expect(status.tables).toHaveProperty('tag_canonical');
|
|
39
|
+
expect(status.tables).toHaveProperty('tag_alias');
|
|
40
|
+
expect(status.tables).toHaveProperty('changelog');
|
|
41
|
+
expect(status.tables).toHaveProperty('contradictions');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should seed default tag aliases', () => {
|
|
45
|
+
const tags = curator.getCanonicalTags();
|
|
46
|
+
const tagNames = tags.map((t) => t.tag);
|
|
47
|
+
expect(tagNames).toContain('accessibility');
|
|
48
|
+
expect(tagNames).toContain('typescript');
|
|
49
|
+
expect(tagNames).toContain('javascript');
|
|
50
|
+
expect(tagNames).toContain('styling');
|
|
51
|
+
expect(tagNames).toContain('testing');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should be idempotent — safe to construct twice on same vault', () => {
|
|
55
|
+
const curator2 = new Curator(vault);
|
|
56
|
+
const status = curator2.getStatus();
|
|
57
|
+
expect(status.initialized).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── Status ───────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('Status', () => {
|
|
64
|
+
it('should return table row counts', () => {
|
|
65
|
+
const status = curator.getStatus();
|
|
66
|
+
expect(status.tables.entry_state).toBe(0);
|
|
67
|
+
expect(status.tables.tag_alias).toBeGreaterThan(0); // seeded aliases
|
|
68
|
+
expect(status.tables.tag_canonical).toBeGreaterThan(0);
|
|
69
|
+
expect(status.tables.changelog).toBe(0);
|
|
70
|
+
expect(status.tables.contradictions).toBe(0);
|
|
71
|
+
expect(status.lastGroomedAt).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── Tag Normalization ────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('Tag Normalization', () => {
|
|
78
|
+
it('should normalize a known alias', () => {
|
|
79
|
+
const result = curator.normalizeTag('a11y');
|
|
80
|
+
expect(result.normalized).toBe('accessibility');
|
|
81
|
+
expect(result.wasAliased).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should return lowercase for unknown tags', () => {
|
|
85
|
+
const result = curator.normalizeTag('MyCustomTag');
|
|
86
|
+
expect(result.normalized).toBe('mycustomtag');
|
|
87
|
+
expect(result.wasAliased).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should normalize tags on an entry', () => {
|
|
91
|
+
vault.seed([makeEntry({ id: 'norm-1', tags: ['a11y', 'ts', 'custom'] })]);
|
|
92
|
+
const results = curator.normalizeTags('norm-1');
|
|
93
|
+
expect(results.length).toBe(3);
|
|
94
|
+
expect(results[0].normalized).toBe('accessibility');
|
|
95
|
+
expect(results[1].normalized).toBe('typescript');
|
|
96
|
+
expect(results[2].normalized).toBe('custom');
|
|
97
|
+
|
|
98
|
+
// Verify the entry was updated in vault
|
|
99
|
+
const updated = vault.get('norm-1')!;
|
|
100
|
+
expect(updated.tags).toContain('accessibility');
|
|
101
|
+
expect(updated.tags).toContain('typescript');
|
|
102
|
+
expect(updated.tags).not.toContain('a11y');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should add a custom alias', () => {
|
|
106
|
+
curator.addTagAlias('react', 'frontend');
|
|
107
|
+
const result = curator.normalizeTag('react');
|
|
108
|
+
expect(result.normalized).toBe('frontend');
|
|
109
|
+
expect(result.wasAliased).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should list canonical tags with alias counts', () => {
|
|
113
|
+
const tags = curator.getCanonicalTags();
|
|
114
|
+
const styling = tags.find((t) => t.tag === 'styling')!;
|
|
115
|
+
expect(styling.aliasCount).toBe(3); // css, tailwind, tw
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─── Duplicate Detection ──────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe('Duplicate Detection', () => {
|
|
122
|
+
it('should detect duplicates above threshold', () => {
|
|
123
|
+
vault.seed([
|
|
124
|
+
makeEntry({
|
|
125
|
+
id: 'dup-1',
|
|
126
|
+
title: 'Use semantic tokens for colors',
|
|
127
|
+
description: 'Always use semantic tokens instead of raw hex values for color styling.',
|
|
128
|
+
}),
|
|
129
|
+
makeEntry({
|
|
130
|
+
id: 'dup-2',
|
|
131
|
+
title: 'Use semantic tokens for color values',
|
|
132
|
+
description: 'Prefer semantic color tokens over raw hex or rgb values in styling.',
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
const results = curator.detectDuplicates(undefined, 0.3);
|
|
136
|
+
expect(results.length).toBeGreaterThan(0);
|
|
137
|
+
// Both should find each other
|
|
138
|
+
const dup1 = results.find((r) => r.entryId === 'dup-1');
|
|
139
|
+
expect(dup1).toBeDefined();
|
|
140
|
+
expect(dup1!.matches.length).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should not detect duplicates below threshold', () => {
|
|
144
|
+
vault.seed([
|
|
145
|
+
makeEntry({
|
|
146
|
+
id: 'uniq-1',
|
|
147
|
+
title: 'Database indexing strategies',
|
|
148
|
+
description: 'Create indices on frequently queried columns.',
|
|
149
|
+
}),
|
|
150
|
+
makeEntry({
|
|
151
|
+
id: 'uniq-2',
|
|
152
|
+
title: 'React component lifecycle',
|
|
153
|
+
description: 'Use useEffect for side effects in functional components.',
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
const results = curator.detectDuplicates(undefined, 0.8);
|
|
157
|
+
expect(results.length).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should detect duplicates for a specific entry', () => {
|
|
161
|
+
vault.seed([
|
|
162
|
+
makeEntry({
|
|
163
|
+
id: 'spec-1',
|
|
164
|
+
title: 'Authentication with JWT tokens',
|
|
165
|
+
description: 'Use JSON Web Tokens for stateless authentication.',
|
|
166
|
+
}),
|
|
167
|
+
makeEntry({
|
|
168
|
+
id: 'spec-2',
|
|
169
|
+
title: 'JWT token authentication pattern',
|
|
170
|
+
description: 'Implement JWT-based authentication for API endpoints.',
|
|
171
|
+
}),
|
|
172
|
+
makeEntry({
|
|
173
|
+
id: 'spec-3',
|
|
174
|
+
title: 'Database connection pooling',
|
|
175
|
+
description: 'Use connection pools for efficient database access.',
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
const results = curator.detectDuplicates('spec-1', 0.3);
|
|
179
|
+
expect(results.length).toBe(1);
|
|
180
|
+
expect(results[0].entryId).toBe('spec-1');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should suggest merge for high similarity', () => {
|
|
184
|
+
vault.seed([
|
|
185
|
+
makeEntry({
|
|
186
|
+
id: 'merge-1',
|
|
187
|
+
title: 'Validate user input',
|
|
188
|
+
description: 'Always validate and sanitize user input before processing.',
|
|
189
|
+
}),
|
|
190
|
+
makeEntry({
|
|
191
|
+
id: 'merge-2',
|
|
192
|
+
title: 'Validate user input',
|
|
193
|
+
description: 'Always validate and sanitize user input before processing.',
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
const results = curator.detectDuplicates(undefined, 0.3);
|
|
197
|
+
if (results.length > 0 && results[0].matches.length > 0) {
|
|
198
|
+
expect(results[0].matches[0].suggestMerge).toBe(true);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should return empty for empty vault', () => {
|
|
203
|
+
const results = curator.detectDuplicates();
|
|
204
|
+
expect(results).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── Contradictions ───────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe('Contradictions', () => {
|
|
211
|
+
it('should detect contradiction between similar pattern and anti-pattern', () => {
|
|
212
|
+
vault.seed([
|
|
213
|
+
makeEntry({
|
|
214
|
+
id: 'p-inline',
|
|
215
|
+
type: 'pattern',
|
|
216
|
+
title: 'Use inline styles for dynamic values',
|
|
217
|
+
description: 'Apply inline styles when values are computed at runtime.',
|
|
218
|
+
tags: ['styling'],
|
|
219
|
+
}),
|
|
220
|
+
makeEntry({
|
|
221
|
+
id: 'ap-inline',
|
|
222
|
+
type: 'anti-pattern',
|
|
223
|
+
title: 'Avoid inline styles for styling',
|
|
224
|
+
description:
|
|
225
|
+
'Never use inline styles — prefer CSS classes or Tailwind utilities for styling.',
|
|
226
|
+
tags: ['styling'],
|
|
227
|
+
}),
|
|
228
|
+
]);
|
|
229
|
+
const contradictions = curator.detectContradictions(0.2);
|
|
230
|
+
expect(contradictions.length).toBeGreaterThan(0);
|
|
231
|
+
expect(contradictions[0].patternId).toBe('p-inline');
|
|
232
|
+
expect(contradictions[0].antipatternId).toBe('ap-inline');
|
|
233
|
+
expect(contradictions[0].status).toBe('open');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should skip unrelated pattern/anti-pattern pairs', () => {
|
|
237
|
+
vault.seed([
|
|
238
|
+
makeEntry({
|
|
239
|
+
id: 'p-auth',
|
|
240
|
+
type: 'pattern',
|
|
241
|
+
title: 'Use JWT for authentication',
|
|
242
|
+
description: 'JSON Web Tokens for stateless auth.',
|
|
243
|
+
tags: ['auth'],
|
|
244
|
+
}),
|
|
245
|
+
makeEntry({
|
|
246
|
+
id: 'ap-css',
|
|
247
|
+
type: 'anti-pattern',
|
|
248
|
+
title: 'Avoid CSS !important',
|
|
249
|
+
description: 'Never use !important in CSS declarations.',
|
|
250
|
+
tags: ['styling'],
|
|
251
|
+
}),
|
|
252
|
+
]);
|
|
253
|
+
const contradictions = curator.detectContradictions(0.8);
|
|
254
|
+
expect(contradictions.length).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should respect UNIQUE constraint — no duplicate contradictions', () => {
|
|
258
|
+
vault.seed([
|
|
259
|
+
makeEntry({
|
|
260
|
+
id: 'p-dup',
|
|
261
|
+
type: 'pattern',
|
|
262
|
+
title: 'Use inline styles',
|
|
263
|
+
description: 'Apply inline styles for dynamic values.',
|
|
264
|
+
}),
|
|
265
|
+
makeEntry({
|
|
266
|
+
id: 'ap-dup',
|
|
267
|
+
type: 'anti-pattern',
|
|
268
|
+
title: 'Avoid inline styles',
|
|
269
|
+
description: 'Do not use inline styles.',
|
|
270
|
+
}),
|
|
271
|
+
]);
|
|
272
|
+
curator.detectContradictions(0.2);
|
|
273
|
+
const first = curator.getContradictions();
|
|
274
|
+
curator.detectContradictions(0.2);
|
|
275
|
+
const second = curator.getContradictions();
|
|
276
|
+
expect(second.length).toBe(first.length);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should resolve a contradiction', () => {
|
|
280
|
+
vault.seed([
|
|
281
|
+
makeEntry({
|
|
282
|
+
id: 'p-res',
|
|
283
|
+
type: 'pattern',
|
|
284
|
+
title: 'Use inline styles',
|
|
285
|
+
description: 'Apply inline styles.',
|
|
286
|
+
}),
|
|
287
|
+
makeEntry({
|
|
288
|
+
id: 'ap-res',
|
|
289
|
+
type: 'anti-pattern',
|
|
290
|
+
title: 'Avoid inline styles',
|
|
291
|
+
description: 'Do not use inline styles.',
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
curator.detectContradictions(0.2);
|
|
295
|
+
const all = curator.getContradictions();
|
|
296
|
+
expect(all.length).toBeGreaterThan(0);
|
|
297
|
+
const resolved = curator.resolveContradiction(all[0].id, 'resolved');
|
|
298
|
+
expect(resolved).not.toBeNull();
|
|
299
|
+
expect(resolved!.status).toBe('resolved');
|
|
300
|
+
expect(resolved!.resolvedAt).not.toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should dismiss a contradiction', () => {
|
|
304
|
+
vault.seed([
|
|
305
|
+
makeEntry({
|
|
306
|
+
id: 'p-dis',
|
|
307
|
+
type: 'pattern',
|
|
308
|
+
title: 'Use inline styles',
|
|
309
|
+
description: 'Apply inline styles.',
|
|
310
|
+
}),
|
|
311
|
+
makeEntry({
|
|
312
|
+
id: 'ap-dis',
|
|
313
|
+
type: 'anti-pattern',
|
|
314
|
+
title: 'Avoid inline styles',
|
|
315
|
+
description: 'Do not use inline styles.',
|
|
316
|
+
}),
|
|
317
|
+
]);
|
|
318
|
+
curator.detectContradictions(0.2);
|
|
319
|
+
const all = curator.getContradictions();
|
|
320
|
+
const dismissed = curator.resolveContradiction(all[0].id, 'dismissed');
|
|
321
|
+
expect(dismissed!.status).toBe('dismissed');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should list by status', () => {
|
|
325
|
+
vault.seed([
|
|
326
|
+
makeEntry({
|
|
327
|
+
id: 'p-ls',
|
|
328
|
+
type: 'pattern',
|
|
329
|
+
title: 'Use inline styles',
|
|
330
|
+
description: 'Apply inline styles.',
|
|
331
|
+
}),
|
|
332
|
+
makeEntry({
|
|
333
|
+
id: 'ap-ls',
|
|
334
|
+
type: 'anti-pattern',
|
|
335
|
+
title: 'Avoid inline styles',
|
|
336
|
+
description: 'Do not use inline styles.',
|
|
337
|
+
}),
|
|
338
|
+
]);
|
|
339
|
+
curator.detectContradictions(0.2);
|
|
340
|
+
const open = curator.getContradictions('open');
|
|
341
|
+
expect(open.length).toBeGreaterThan(0);
|
|
342
|
+
const resolved = curator.getContradictions('resolved');
|
|
343
|
+
expect(resolved.length).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ─── Grooming ─────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
describe('Grooming', () => {
|
|
350
|
+
it('should groom a single entry', () => {
|
|
351
|
+
vault.seed([makeEntry({ id: 'groom-1', tags: ['a11y', 'perf'] })]);
|
|
352
|
+
const result = curator.groomEntry('groom-1');
|
|
353
|
+
expect(result).not.toBeNull();
|
|
354
|
+
expect(result!.entryId).toBe('groom-1');
|
|
355
|
+
expect(result!.stale).toBe(false);
|
|
356
|
+
expect(result!.lastGroomedAt).toBeGreaterThan(0);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should detect stale entries during grooming', () => {
|
|
360
|
+
vault.seed([makeEntry({ id: 'groom-stale' })]);
|
|
361
|
+
// Manually set updated_at to 100 days ago
|
|
362
|
+
const db = vault.getDb();
|
|
363
|
+
const oldTimestamp = Math.floor(Date.now() / 1000) - 100 * 86400;
|
|
364
|
+
db.prepare('UPDATE entries SET updated_at = ? WHERE id = ?').run(oldTimestamp, 'groom-stale');
|
|
365
|
+
|
|
366
|
+
const result = curator.groomEntry('groom-stale');
|
|
367
|
+
expect(result!.stale).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should update curator_entry_state after grooming', () => {
|
|
371
|
+
vault.seed([makeEntry({ id: 'groom-state' })]);
|
|
372
|
+
curator.groomEntry('groom-state');
|
|
373
|
+
|
|
374
|
+
const db = vault.getDb();
|
|
375
|
+
const row = db
|
|
376
|
+
.prepare('SELECT * FROM curator_entry_state WHERE entry_id = ?')
|
|
377
|
+
.get('groom-state') as Record<string, unknown>;
|
|
378
|
+
expect(row).toBeDefined();
|
|
379
|
+
expect(row.status).toBe('active');
|
|
380
|
+
expect(row.last_groomed_at).not.toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should groom all entries', () => {
|
|
384
|
+
vault.seed([
|
|
385
|
+
makeEntry({ id: 'ga-1', tags: ['ts'] }),
|
|
386
|
+
makeEntry({ id: 'ga-2', tags: ['js'] }),
|
|
387
|
+
makeEntry({ id: 'ga-3', tags: ['custom'] }),
|
|
388
|
+
]);
|
|
389
|
+
const result = curator.groomAll();
|
|
390
|
+
expect(result.totalEntries).toBe(3);
|
|
391
|
+
expect(result.groomedCount).toBe(3);
|
|
392
|
+
expect(result.tagsNormalized).toBe(2); // ts→typescript, js→javascript
|
|
393
|
+
expect(result.staleCount).toBe(0);
|
|
394
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should log changelog entries during grooming', () => {
|
|
398
|
+
vault.seed([makeEntry({ id: 'groom-log' })]);
|
|
399
|
+
curator.groomEntry('groom-log');
|
|
400
|
+
const history = curator.getEntryHistory('groom-log');
|
|
401
|
+
expect(history.length).toBeGreaterThan(0);
|
|
402
|
+
expect(history[0].action).toBe('groom');
|
|
403
|
+
expect(history[0].entryId).toBe('groom-log');
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ─── Consolidation ────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe('Consolidation', () => {
|
|
410
|
+
it('should default to dry-run', () => {
|
|
411
|
+
const result = curator.consolidate();
|
|
412
|
+
expect(result.dryRun).toBe(true);
|
|
413
|
+
expect(result.mutations).toBe(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should find issues in dry-run', () => {
|
|
417
|
+
vault.seed([
|
|
418
|
+
makeEntry({
|
|
419
|
+
id: 'con-1',
|
|
420
|
+
title: 'Validate user input thoroughly',
|
|
421
|
+
description: 'Always validate and sanitize all user input before processing.',
|
|
422
|
+
}),
|
|
423
|
+
makeEntry({
|
|
424
|
+
id: 'con-2',
|
|
425
|
+
title: 'Validate user input thoroughly',
|
|
426
|
+
description: 'Always validate and sanitize all user input before processing.',
|
|
427
|
+
}),
|
|
428
|
+
]);
|
|
429
|
+
const result = curator.consolidate({ dryRun: true, duplicateThreshold: 0.3 });
|
|
430
|
+
expect(result.dryRun).toBe(true);
|
|
431
|
+
expect(result.duplicates.length).toBeGreaterThan(0);
|
|
432
|
+
expect(result.mutations).toBe(0);
|
|
433
|
+
// Entries still exist
|
|
434
|
+
expect(vault.get('con-1')).not.toBeNull();
|
|
435
|
+
expect(vault.get('con-2')).not.toBeNull();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should not mutate in dry-run', () => {
|
|
439
|
+
vault.seed([
|
|
440
|
+
makeEntry({ id: 'dry-1', title: 'Identical pattern', description: 'Same thing.' }),
|
|
441
|
+
makeEntry({ id: 'dry-2', title: 'Identical pattern', description: 'Same thing.' }),
|
|
442
|
+
]);
|
|
443
|
+
curator.consolidate({ dryRun: true, duplicateThreshold: 0.3 });
|
|
444
|
+
expect(vault.get('dry-1')).not.toBeNull();
|
|
445
|
+
expect(vault.get('dry-2')).not.toBeNull();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should archive stale entries when not dry-run', () => {
|
|
449
|
+
vault.seed([makeEntry({ id: 'stale-con' })]);
|
|
450
|
+
const db = vault.getDb();
|
|
451
|
+
const oldTimestamp = Math.floor(Date.now() / 1000) - 100 * 86400;
|
|
452
|
+
db.prepare('UPDATE entries SET updated_at = ? WHERE id = ?').run(oldTimestamp, 'stale-con');
|
|
453
|
+
|
|
454
|
+
const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
|
|
455
|
+
expect(result.staleEntries).toContain('stale-con');
|
|
456
|
+
expect(result.mutations).toBeGreaterThan(0);
|
|
457
|
+
|
|
458
|
+
// Check that entry state was archived
|
|
459
|
+
const row = db
|
|
460
|
+
.prepare('SELECT status FROM curator_entry_state WHERE entry_id = ?')
|
|
461
|
+
.get('stale-con') as { status: string };
|
|
462
|
+
expect(row.status).toBe('archived');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should remove duplicates when not dry-run', () => {
|
|
466
|
+
vault.seed([
|
|
467
|
+
makeEntry({
|
|
468
|
+
id: 'rem-1',
|
|
469
|
+
title: 'Duplicate pattern for removal',
|
|
470
|
+
description: 'This is a duplicate pattern that should be removed during consolidation.',
|
|
471
|
+
}),
|
|
472
|
+
makeEntry({
|
|
473
|
+
id: 'rem-2',
|
|
474
|
+
title: 'Duplicate pattern for removal',
|
|
475
|
+
description: 'This is a duplicate pattern that should be removed during consolidation.',
|
|
476
|
+
}),
|
|
477
|
+
]);
|
|
478
|
+
const result = curator.consolidate({ dryRun: false, duplicateThreshold: 0.3 });
|
|
479
|
+
expect(result.mutations).toBeGreaterThan(0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should log consolidation actions to changelog', () => {
|
|
483
|
+
vault.seed([makeEntry({ id: 'con-log' })]);
|
|
484
|
+
const db = vault.getDb();
|
|
485
|
+
const oldTimestamp = Math.floor(Date.now() / 1000) - 100 * 86400;
|
|
486
|
+
db.prepare('UPDATE entries SET updated_at = ? WHERE id = ?').run(oldTimestamp, 'con-log');
|
|
487
|
+
|
|
488
|
+
curator.consolidate({ dryRun: false });
|
|
489
|
+
const history = curator.getEntryHistory('con-log');
|
|
490
|
+
expect(history.length).toBeGreaterThan(0);
|
|
491
|
+
expect(history.some((h) => h.action === 'archive')).toBe(true);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// ─── Changelog ────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
describe('Changelog', () => {
|
|
498
|
+
it('should return entries in reverse chronological order', () => {
|
|
499
|
+
vault.seed([makeEntry({ id: 'chg-1', tags: ['a11y'] })]);
|
|
500
|
+
curator.groomEntry('chg-1');
|
|
501
|
+
curator.groomEntry('chg-1');
|
|
502
|
+
const history = curator.getEntryHistory('chg-1');
|
|
503
|
+
expect(history.length).toBe(3); // normalize_tags + 2 grooms
|
|
504
|
+
// Most recent first
|
|
505
|
+
expect(history[0].createdAt).toBeGreaterThanOrEqual(history[history.length - 1].createdAt);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should respect limit', () => {
|
|
509
|
+
vault.seed([makeEntry({ id: 'chg-lim', tags: ['ts'] })]);
|
|
510
|
+
curator.groomEntry('chg-lim');
|
|
511
|
+
curator.groomEntry('chg-lim');
|
|
512
|
+
const history = curator.getEntryHistory('chg-lim', 1);
|
|
513
|
+
expect(history.length).toBe(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should return empty for unknown entry', () => {
|
|
517
|
+
const history = curator.getEntryHistory('nonexistent');
|
|
518
|
+
expect(history).toEqual([]);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ─── Health Audit ─────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
describe('Health Audit', () => {
|
|
525
|
+
it('should return 100 for healthy vault', () => {
|
|
526
|
+
vault.seed([
|
|
527
|
+
makeEntry({ id: 'h-1', type: 'pattern', tags: ['a', 'b'] }),
|
|
528
|
+
makeEntry({ id: 'h-2', type: 'anti-pattern', tags: ['c', 'd'] }),
|
|
529
|
+
makeEntry({ id: 'h-3', type: 'rule', tags: ['e', 'f'] }),
|
|
530
|
+
]);
|
|
531
|
+
// Groom all entries
|
|
532
|
+
curator.groomAll();
|
|
533
|
+
const result = curator.healthAudit();
|
|
534
|
+
expect(result.score).toBeGreaterThanOrEqual(80);
|
|
535
|
+
expect(result.metrics.coverage).toBe(1);
|
|
536
|
+
expect(result.metrics.freshness).toBe(1);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should penalize for missing entry types', () => {
|
|
540
|
+
vault.seed([makeEntry({ id: 'h-p1', type: 'pattern', tags: ['x'] })]);
|
|
541
|
+
curator.groomAll();
|
|
542
|
+
const result = curator.healthAudit();
|
|
543
|
+
expect(result.score).toBeLessThan(100);
|
|
544
|
+
expect(result.recommendations.some((r) => r.includes('anti-pattern'))).toBe(true);
|
|
545
|
+
expect(result.recommendations.some((r) => r.includes('rule'))).toBe(true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('should recommend actions for issues', () => {
|
|
549
|
+
vault.seed([makeEntry({ id: 'h-rec', type: 'pattern', tags: [] })]);
|
|
550
|
+
const result = curator.healthAudit();
|
|
551
|
+
expect(result.recommendations.length).toBeGreaterThan(0);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should handle empty vault gracefully', () => {
|
|
555
|
+
const result = curator.healthAudit();
|
|
556
|
+
expect(result.score).toBe(100);
|
|
557
|
+
expect(result.recommendations).toContain(
|
|
558
|
+
'Vault is empty — add knowledge entries to get started.',
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should include tag health metrics', () => {
|
|
563
|
+
vault.seed([
|
|
564
|
+
makeEntry({ id: 'h-tag1', tags: [] }),
|
|
565
|
+
makeEntry({ id: 'h-tag2', tags: ['one'] }),
|
|
566
|
+
makeEntry({ id: 'h-tag3', tags: ['one', 'two'] }),
|
|
567
|
+
]);
|
|
568
|
+
const result = curator.healthAudit();
|
|
569
|
+
expect(result.metrics.tagHealth).toBeDefined();
|
|
570
|
+
// 2 out of 3 entries have < 2 tags
|
|
571
|
+
expect(result.metrics.tagHealth).toBeLessThan(1);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
});
|