tlc-claude-code 1.8.5 → 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/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file test-inventory.test.js
|
|
3
|
+
* @description Tests for the Test Suite Inventory API (Phase 75, Task 2).
|
|
4
|
+
*
|
|
5
|
+
* Tests the factory function `createTestInventory(deps)` which accepts injected
|
|
6
|
+
* dependencies (globSync, fs) and returns functions for discovering test files,
|
|
7
|
+
* counting tests per file, grouping by directory, and reading cached test runs.
|
|
8
|
+
*
|
|
9
|
+
* Factory returns: { getTestInventory(projectPath), getLastTestRun(projectPath) }
|
|
10
|
+
*
|
|
11
|
+
* All dependencies are fully mocked with vi.fn() — no real filesystem access.
|
|
12
|
+
* These tests are written BEFORE the implementation (Red phase).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
15
|
+
import { createTestInventory } from './test-inventory.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Mock factories
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a mock globSync function that returns the given file paths.
|
|
23
|
+
* @param {string[]} files - Array of file paths to return from globSync
|
|
24
|
+
* @returns {Function} vi.fn() that returns the files array
|
|
25
|
+
*/
|
|
26
|
+
function createMockGlob(files = []) {
|
|
27
|
+
return vi.fn().mockReturnValue(files);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a mock fs object with readFileSync and existsSync stubs.
|
|
32
|
+
* readFileSync returns content from the fileContents map, or throws ENOENT.
|
|
33
|
+
* existsSync returns true if the path exists in the map.
|
|
34
|
+
* @param {Object<string, string>} fileContents - Map of file path to file content
|
|
35
|
+
* @returns {{ readFileSync: Function, existsSync: Function }} Mock fs object
|
|
36
|
+
*/
|
|
37
|
+
function createMockFs(fileContents = {}) {
|
|
38
|
+
return {
|
|
39
|
+
readFileSync: vi.fn((p) => {
|
|
40
|
+
if (p in fileContents) return fileContents[p];
|
|
41
|
+
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
|
42
|
+
}),
|
|
43
|
+
existsSync: vi.fn((p) => p in fileContents),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Sample test file contents for counting
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** A typical test file with 3 tests (2 it + 1 test) */
|
|
52
|
+
const threeTestFile = `
|
|
53
|
+
import { describe, it, expect } from 'vitest';
|
|
54
|
+
|
|
55
|
+
describe('auth', () => {
|
|
56
|
+
it('logs in with valid credentials', () => {
|
|
57
|
+
expect(true).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects invalid password', () => {
|
|
61
|
+
expect(true).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('handles missing email', () => {
|
|
65
|
+
expect(true).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/** A test file with 2 tests using it.only and it.skip */
|
|
71
|
+
const twoTestFileWithModifiers = `
|
|
72
|
+
import { describe, it, expect } from 'vitest';
|
|
73
|
+
|
|
74
|
+
describe('session', () => {
|
|
75
|
+
it.only('creates new session', () => {
|
|
76
|
+
expect(true).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.skip('expires after timeout', () => {
|
|
80
|
+
expect(true).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/** A test file with 1 test using test.only */
|
|
86
|
+
const oneTestFile = `
|
|
87
|
+
import { describe, test, expect } from 'vitest';
|
|
88
|
+
|
|
89
|
+
describe('utils', () => {
|
|
90
|
+
test.only('formats date correctly', () => {
|
|
91
|
+
expect(true).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
/** An empty test file (no it/test calls) */
|
|
97
|
+
const emptyTestFile = `
|
|
98
|
+
import { describe, expect } from 'vitest';
|
|
99
|
+
|
|
100
|
+
describe('placeholder', () => {
|
|
101
|
+
// TODO: add tests
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tests
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe('test-inventory', () => {
|
|
110
|
+
let inventory;
|
|
111
|
+
let mockGlob;
|
|
112
|
+
let mockFs;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
mockGlob = createMockGlob();
|
|
116
|
+
mockFs = createMockFs();
|
|
117
|
+
inventory = createTestInventory({ globSync: mockGlob, fs: mockFs });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('getTestInventory', () => {
|
|
121
|
+
it('discovers test files by glob patterns', () => {
|
|
122
|
+
const files = [
|
|
123
|
+
'/project/server/lib/auth.test.js',
|
|
124
|
+
'/project/server/lib/tasks.test.ts',
|
|
125
|
+
'/project/dashboard-web/src/App.test.tsx',
|
|
126
|
+
'/project/server/lib/utils.spec.js',
|
|
127
|
+
];
|
|
128
|
+
mockGlob.mockReturnValue(files);
|
|
129
|
+
mockFs.readFileSync.mockReturnValue(emptyTestFile);
|
|
130
|
+
|
|
131
|
+
inventory.getTestInventory('/project');
|
|
132
|
+
|
|
133
|
+
// Verify globSync was called with patterns that cover all test extensions
|
|
134
|
+
const callArgs = mockGlob.mock.calls[0];
|
|
135
|
+
const patterns = callArgs[0];
|
|
136
|
+
|
|
137
|
+
// Should search for .test.js, .test.ts, .test.tsx, and .spec.* patterns
|
|
138
|
+
expect(patterns).toEqual(
|
|
139
|
+
expect.arrayContaining([
|
|
140
|
+
expect.stringContaining('*.test.js'),
|
|
141
|
+
expect.stringContaining('*.test.ts'),
|
|
142
|
+
expect.stringContaining('*.test.tsx'),
|
|
143
|
+
expect.stringContaining('*.spec.*'),
|
|
144
|
+
])
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('groups files by directory', () => {
|
|
149
|
+
const files = [
|
|
150
|
+
'/project/server/lib/auth.test.js',
|
|
151
|
+
'/project/server/lib/tasks.test.js',
|
|
152
|
+
'/project/dashboard-web/src/Login.test.tsx',
|
|
153
|
+
'/project/dashboard-web/src/App.test.tsx',
|
|
154
|
+
];
|
|
155
|
+
mockGlob.mockReturnValue(files);
|
|
156
|
+
mockFs.readFileSync.mockReturnValue(threeTestFile);
|
|
157
|
+
|
|
158
|
+
const result = inventory.getTestInventory('/project');
|
|
159
|
+
|
|
160
|
+
expect(result.groups).toHaveLength(2);
|
|
161
|
+
|
|
162
|
+
const groupNames = result.groups.map((g) => g.name);
|
|
163
|
+
expect(groupNames).toContain('server/lib');
|
|
164
|
+
expect(groupNames).toContain('dashboard-web/src');
|
|
165
|
+
|
|
166
|
+
const serverGroup = result.groups.find((g) => g.name === 'server/lib');
|
|
167
|
+
expect(serverGroup.fileCount).toBe(2);
|
|
168
|
+
|
|
169
|
+
const dashGroup = result.groups.find((g) => g.name === 'dashboard-web/src');
|
|
170
|
+
expect(dashGroup.fileCount).toBe(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('counts tests per file (it/test occurrences)', () => {
|
|
174
|
+
const files = ['/project/server/lib/auth.test.js'];
|
|
175
|
+
mockGlob.mockReturnValue(files);
|
|
176
|
+
mockFs.readFileSync.mockReturnValue(threeTestFile);
|
|
177
|
+
|
|
178
|
+
const result = inventory.getTestInventory('/project');
|
|
179
|
+
|
|
180
|
+
const file = result.groups[0].files[0];
|
|
181
|
+
expect(file.testCount).toBe(3);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('summary totals are correct', () => {
|
|
185
|
+
const files = [
|
|
186
|
+
'/project/server/lib/auth.test.js',
|
|
187
|
+
'/project/server/lib/session.test.js',
|
|
188
|
+
'/project/server/lib/utils.test.js',
|
|
189
|
+
];
|
|
190
|
+
mockGlob.mockReturnValue(files);
|
|
191
|
+
|
|
192
|
+
// auth has 3, session has 2, utils has 1 = 6 total
|
|
193
|
+
const contents = {
|
|
194
|
+
'/project/server/lib/auth.test.js': threeTestFile,
|
|
195
|
+
'/project/server/lib/session.test.js': twoTestFileWithModifiers,
|
|
196
|
+
'/project/server/lib/utils.test.js': oneTestFile,
|
|
197
|
+
};
|
|
198
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
199
|
+
if (p in contents) return contents[p];
|
|
200
|
+
throw new Error(`ENOENT: ${p}`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const result = inventory.getTestInventory('/project');
|
|
204
|
+
|
|
205
|
+
expect(result.totalFiles).toBe(3);
|
|
206
|
+
expect(result.totalTests).toBe(6);
|
|
207
|
+
|
|
208
|
+
// Group total should also sum correctly
|
|
209
|
+
const group = result.groups[0];
|
|
210
|
+
expect(group.testCount).toBe(6);
|
|
211
|
+
expect(group.fileCount).toBe(3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('groups sorted by test count descending', () => {
|
|
215
|
+
const files = [
|
|
216
|
+
// Group A: server/lib — 1 file with 1 test
|
|
217
|
+
'/project/server/lib/utils.test.js',
|
|
218
|
+
// Group B: dashboard-web/src — 1 file with 3 tests
|
|
219
|
+
'/project/dashboard-web/src/App.test.tsx',
|
|
220
|
+
];
|
|
221
|
+
mockGlob.mockReturnValue(files);
|
|
222
|
+
|
|
223
|
+
const contents = {
|
|
224
|
+
'/project/server/lib/utils.test.js': oneTestFile,
|
|
225
|
+
'/project/dashboard-web/src/App.test.tsx': threeTestFile,
|
|
226
|
+
};
|
|
227
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
228
|
+
if (p in contents) return contents[p];
|
|
229
|
+
throw new Error(`ENOENT: ${p}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = inventory.getTestInventory('/project');
|
|
233
|
+
|
|
234
|
+
// dashboard-web/src has 3 tests, server/lib has 1 — dashboard first
|
|
235
|
+
expect(result.groups[0].name).toBe('dashboard-web/src');
|
|
236
|
+
expect(result.groups[0].testCount).toBe(3);
|
|
237
|
+
expect(result.groups[1].name).toBe('server/lib');
|
|
238
|
+
expect(result.groups[1].testCount).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('ignores node_modules and dist', () => {
|
|
242
|
+
mockGlob.mockReturnValue([]);
|
|
243
|
+
|
|
244
|
+
inventory.getTestInventory('/project');
|
|
245
|
+
|
|
246
|
+
const callArgs = mockGlob.mock.calls[0];
|
|
247
|
+
const options = callArgs[1];
|
|
248
|
+
|
|
249
|
+
// The options should include ignore patterns for node_modules, dist, .git
|
|
250
|
+
expect(options).toBeDefined();
|
|
251
|
+
expect(options.ignore).toEqual(
|
|
252
|
+
expect.arrayContaining([
|
|
253
|
+
expect.stringContaining('node_modules'),
|
|
254
|
+
expect.stringContaining('dist'),
|
|
255
|
+
expect.stringContaining('.git'),
|
|
256
|
+
])
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns empty inventory for no test files', () => {
|
|
261
|
+
mockGlob.mockReturnValue([]);
|
|
262
|
+
|
|
263
|
+
const result = inventory.getTestInventory('/project');
|
|
264
|
+
|
|
265
|
+
expect(result.totalFiles).toBe(0);
|
|
266
|
+
expect(result.totalTests).toBe(0);
|
|
267
|
+
expect(result.groups).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('handles mixed extensions (.test.js, .test.ts, .test.tsx)', () => {
|
|
271
|
+
const files = [
|
|
272
|
+
'/project/src/auth.test.js',
|
|
273
|
+
'/project/src/config.test.ts',
|
|
274
|
+
'/project/src/App.test.tsx',
|
|
275
|
+
];
|
|
276
|
+
mockGlob.mockReturnValue(files);
|
|
277
|
+
mockFs.readFileSync.mockReturnValue(oneTestFile);
|
|
278
|
+
|
|
279
|
+
const result = inventory.getTestInventory('/project');
|
|
280
|
+
|
|
281
|
+
expect(result.totalFiles).toBe(3);
|
|
282
|
+
// All three files should be discovered regardless of extension
|
|
283
|
+
const filePaths = result.groups[0].files.map((f) => f.relativePath);
|
|
284
|
+
expect(filePaths).toContain('src/auth.test.js');
|
|
285
|
+
expect(filePaths).toContain('src/config.test.ts');
|
|
286
|
+
expect(filePaths).toContain('src/App.test.tsx');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('handles spec files (.spec.js)', () => {
|
|
290
|
+
const files = [
|
|
291
|
+
'/project/src/utils.spec.js',
|
|
292
|
+
'/project/src/helpers.spec.ts',
|
|
293
|
+
];
|
|
294
|
+
mockGlob.mockReturnValue(files);
|
|
295
|
+
mockFs.readFileSync.mockReturnValue(threeTestFile);
|
|
296
|
+
|
|
297
|
+
const result = inventory.getTestInventory('/project');
|
|
298
|
+
|
|
299
|
+
expect(result.totalFiles).toBe(2);
|
|
300
|
+
const filePaths = result.groups[0].files.map((f) => f.relativePath);
|
|
301
|
+
expect(filePaths).toContain('src/utils.spec.js');
|
|
302
|
+
expect(filePaths).toContain('src/helpers.spec.ts');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('works with nested directory structures', () => {
|
|
306
|
+
const files = [
|
|
307
|
+
'/project/server/lib/auth/login.test.js',
|
|
308
|
+
'/project/server/lib/auth/logout.test.js',
|
|
309
|
+
'/project/server/lib/tasks.test.js',
|
|
310
|
+
'/project/dashboard-web/src/pages/Home.test.tsx',
|
|
311
|
+
];
|
|
312
|
+
mockGlob.mockReturnValue(files);
|
|
313
|
+
mockFs.readFileSync.mockReturnValue(oneTestFile);
|
|
314
|
+
|
|
315
|
+
const result = inventory.getTestInventory('/project');
|
|
316
|
+
|
|
317
|
+
// Nested dirs should be grouped by their top-level directory segments
|
|
318
|
+
const groupNames = result.groups.map((g) => g.name);
|
|
319
|
+
|
|
320
|
+
// server/lib/auth/ and server/lib/ files should group under server/lib
|
|
321
|
+
// (or similar sensible grouping)
|
|
322
|
+
expect(groupNames.length).toBeGreaterThanOrEqual(2);
|
|
323
|
+
|
|
324
|
+
// All 4 files should be accounted for
|
|
325
|
+
expect(result.totalFiles).toBe(4);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('getLastTestRun', () => {
|
|
330
|
+
it('returns null when no cached run exists', () => {
|
|
331
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
332
|
+
|
|
333
|
+
const result = inventory.getLastTestRun('/project');
|
|
334
|
+
|
|
335
|
+
expect(result).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns cached test run data when available', () => {
|
|
339
|
+
const cachedRun = JSON.stringify({
|
|
340
|
+
timestamp: '2026-02-09T10:30:00Z',
|
|
341
|
+
passed: 347,
|
|
342
|
+
failed: 3,
|
|
343
|
+
total: 350,
|
|
344
|
+
duration: 12500,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
348
|
+
mockFs.readFileSync.mockReturnValue(cachedRun);
|
|
349
|
+
|
|
350
|
+
const result = inventory.getLastTestRun('/project');
|
|
351
|
+
|
|
352
|
+
expect(result).not.toBeNull();
|
|
353
|
+
expect(result.timestamp).toBe('2026-02-09T10:30:00Z');
|
|
354
|
+
expect(result.passed).toBe(347);
|
|
355
|
+
expect(result.failed).toBe(3);
|
|
356
|
+
expect(result.total).toBe(350);
|
|
357
|
+
expect(result.duration).toBe(12500);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vector Indexer — embeds and indexes memory content into the vector store.
|
|
3
|
+
*
|
|
4
|
+
* Reads markdown files from memory/decisions/, memory/gotchas/, and
|
|
5
|
+
* memory/conversations/, strips formatting, generates embeddings, and
|
|
6
|
+
* inserts into the vector store for semantic search.
|
|
7
|
+
*
|
|
8
|
+
* @module vector-indexer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
/** Memory subdirectories and their types */
|
|
16
|
+
const MEMORY_DIRS = [
|
|
17
|
+
{ dir: 'decisions', type: 'decision' },
|
|
18
|
+
{ dir: 'gotchas', type: 'gotcha' },
|
|
19
|
+
{ dir: 'conversations', type: 'conversation' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Strip markdown formatting from text for cleaner embeddings.
|
|
24
|
+
* @param {string} text
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function stripMarkdown(text) {
|
|
28
|
+
return text
|
|
29
|
+
.replace(/^---[\s\S]*?---\n?/m, '') // Remove frontmatter
|
|
30
|
+
.replace(/^#+\s+/gm, '') // Remove # headers
|
|
31
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
|
|
32
|
+
.replace(/\*([^*]+)\*/g, '$1') // Remove italic
|
|
33
|
+
.replace(/`([^`]+)`/g, '$1') // Remove inline code
|
|
34
|
+
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
35
|
+
.replace(/^\s*[-*]\s+/gm, '') // Remove list markers
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n') // Collapse multiple newlines
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse YAML frontmatter from markdown content.
|
|
42
|
+
* @param {string} content
|
|
43
|
+
* @returns {{ permanent: boolean }}
|
|
44
|
+
*/
|
|
45
|
+
function parseFrontmatter(content) {
|
|
46
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
47
|
+
if (!match) return { permanent: false };
|
|
48
|
+
|
|
49
|
+
const yaml = match[1];
|
|
50
|
+
const permanentMatch = yaml.match(/permanent:\s*(true|false)/i);
|
|
51
|
+
return {
|
|
52
|
+
permanent: permanentMatch ? permanentMatch[1].toLowerCase() === 'true' : false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect memory type from file path based on parent directory.
|
|
58
|
+
* @param {string} filePath
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function detectType(filePath) {
|
|
62
|
+
for (const { dir, type } of MEMORY_DIRS) {
|
|
63
|
+
if (filePath.includes(`/${dir}/`) || filePath.includes(`\\${dir}\\`)) {
|
|
64
|
+
return type;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate a deterministic ID for a file.
|
|
72
|
+
* @param {string} filePath
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function fileId(filePath) {
|
|
76
|
+
return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 16);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* List all .md files in a directory (non-recursive).
|
|
81
|
+
* @param {string} dirPath
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
function listMarkdownFiles(dirPath) {
|
|
85
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
86
|
+
return fs.readdirSync(dirPath)
|
|
87
|
+
.filter((f) => f.endsWith('.md'))
|
|
88
|
+
.map((f) => path.join(dirPath, f))
|
|
89
|
+
.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a vector indexer that embeds and stores memory content.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} deps
|
|
96
|
+
* @param {object} deps.vectorStore - Vector store instance
|
|
97
|
+
* @param {object} deps.embeddingClient - Embedding client instance
|
|
98
|
+
* @returns {object} Indexer with indexAll/indexFile/indexChunk/isIndexed/rebuildIndex
|
|
99
|
+
*/
|
|
100
|
+
export function createVectorIndexer({ vectorStore, embeddingClient }) {
|
|
101
|
+
/**
|
|
102
|
+
* Index all memory files from a project.
|
|
103
|
+
*/
|
|
104
|
+
async function indexAll(projectRoot, options = {}) {
|
|
105
|
+
const { onProgress } = options;
|
|
106
|
+
const memoryRoot = path.join(projectRoot, 'memory');
|
|
107
|
+
|
|
108
|
+
// Collect all files to index
|
|
109
|
+
const filesToIndex = [];
|
|
110
|
+
for (const { dir, type } of MEMORY_DIRS) {
|
|
111
|
+
const dirPath = path.join(memoryRoot, dir);
|
|
112
|
+
const files = listMarkdownFiles(dirPath);
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
filesToIndex.push({ path: f, type });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let indexed = 0;
|
|
119
|
+
let skipped = 0;
|
|
120
|
+
let errors = 0;
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < filesToIndex.length; i++) {
|
|
123
|
+
const file = filesToIndex[i];
|
|
124
|
+
try {
|
|
125
|
+
const result = await indexSingleFile(projectRoot, file.path, file.type);
|
|
126
|
+
if (result.success) {
|
|
127
|
+
indexed++;
|
|
128
|
+
} else {
|
|
129
|
+
errors++;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
errors++;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (onProgress) {
|
|
136
|
+
onProgress({
|
|
137
|
+
indexed: indexed,
|
|
138
|
+
total: filesToIndex.length,
|
|
139
|
+
current: path.basename(file.path),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { indexed, skipped, errors };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Index a single file.
|
|
149
|
+
*/
|
|
150
|
+
async function indexFile(projectRoot, filePath) {
|
|
151
|
+
const type = detectType(filePath);
|
|
152
|
+
return indexSingleFile(projectRoot, filePath, type);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Internal: index a single file with known type.
|
|
157
|
+
*/
|
|
158
|
+
async function indexSingleFile(projectRoot, filePath, type) {
|
|
159
|
+
try {
|
|
160
|
+
const rawContent = fs.readFileSync(filePath, 'utf8');
|
|
161
|
+
const frontmatter = parseFrontmatter(rawContent);
|
|
162
|
+
const cleanText = stripMarkdown(rawContent);
|
|
163
|
+
|
|
164
|
+
if (!cleanText || cleanText.length === 0) {
|
|
165
|
+
return { success: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const embedding = await embeddingClient.embed(cleanText);
|
|
169
|
+
if (!embedding) {
|
|
170
|
+
return { success: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const id = fileId(filePath);
|
|
174
|
+
vectorStore.insert({
|
|
175
|
+
id,
|
|
176
|
+
text: cleanText,
|
|
177
|
+
embedding,
|
|
178
|
+
type,
|
|
179
|
+
project: null,
|
|
180
|
+
workspace: projectRoot,
|
|
181
|
+
branch: null,
|
|
182
|
+
timestamp: Date.now(),
|
|
183
|
+
sourceFile: filePath,
|
|
184
|
+
permanent: frontmatter.permanent,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { success: true };
|
|
188
|
+
} catch {
|
|
189
|
+
return { success: false };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Index a conversation chunk object directly (not from file).
|
|
195
|
+
*/
|
|
196
|
+
async function indexChunk(projectRoot, chunk) {
|
|
197
|
+
try {
|
|
198
|
+
const text = chunk.text || '';
|
|
199
|
+
if (!text) return { success: false };
|
|
200
|
+
|
|
201
|
+
const embedding = await embeddingClient.embed(text);
|
|
202
|
+
if (!embedding) return { success: false };
|
|
203
|
+
|
|
204
|
+
vectorStore.insert({
|
|
205
|
+
id: chunk.id,
|
|
206
|
+
text,
|
|
207
|
+
embedding,
|
|
208
|
+
type: chunk.type || 'conversation',
|
|
209
|
+
project: chunk.project || null,
|
|
210
|
+
workspace: chunk.workspace || projectRoot,
|
|
211
|
+
branch: null,
|
|
212
|
+
timestamp: chunk.timestamp || Date.now(),
|
|
213
|
+
sourceFile: chunk.sourceFile || null,
|
|
214
|
+
permanent: chunk.permanent || false,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return { success: true };
|
|
218
|
+
} catch {
|
|
219
|
+
return { success: false };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a file is already indexed in the store.
|
|
225
|
+
*/
|
|
226
|
+
async function isIndexed(filePath) {
|
|
227
|
+
const all = vectorStore.getAll();
|
|
228
|
+
return all.some((entry) => entry.sourceFile === filePath);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Drop all vectors and re-index everything from text files.
|
|
233
|
+
*/
|
|
234
|
+
async function rebuildIndex(projectRoot) {
|
|
235
|
+
vectorStore.rebuild();
|
|
236
|
+
await indexAll(projectRoot);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
indexAll,
|
|
241
|
+
indexFile,
|
|
242
|
+
indexChunk,
|
|
243
|
+
isIndexed,
|
|
244
|
+
rebuildIndex,
|
|
245
|
+
};
|
|
246
|
+
}
|