bluera-knowledge 0.10.0 → 0.11.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +98 -2
  4. package/commands/sync.md +96 -0
  5. package/dist/{chunk-ITH6FWQY.js → chunk-2WBITQWZ.js} +24 -3
  6. package/dist/{chunk-ITH6FWQY.js.map → chunk-2WBITQWZ.js.map} +1 -1
  7. package/dist/{chunk-CUHYSPRV.js → chunk-565OVW3C.js} +999 -2
  8. package/dist/chunk-565OVW3C.js.map +1 -0
  9. package/dist/{chunk-DWAIT2OD.js → chunk-TRDMYKGC.js} +190 -5
  10. package/dist/chunk-TRDMYKGC.js.map +1 -0
  11. package/dist/index.js +217 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/server.js +2 -2
  14. package/dist/workers/background-worker-cli.js +2 -2
  15. package/package.json +1 -1
  16. package/src/analysis/adapter-registry.test.ts +211 -0
  17. package/src/analysis/adapter-registry.ts +155 -0
  18. package/src/analysis/language-adapter.ts +127 -0
  19. package/src/analysis/parser-factory.test.ts +79 -1
  20. package/src/analysis/parser-factory.ts +8 -0
  21. package/src/analysis/zil/index.ts +34 -0
  22. package/src/analysis/zil/zil-adapter.test.ts +187 -0
  23. package/src/analysis/zil/zil-adapter.ts +121 -0
  24. package/src/analysis/zil/zil-lexer.test.ts +222 -0
  25. package/src/analysis/zil/zil-lexer.ts +239 -0
  26. package/src/analysis/zil/zil-parser.test.ts +210 -0
  27. package/src/analysis/zil/zil-parser.ts +360 -0
  28. package/src/analysis/zil/zil-special-forms.ts +193 -0
  29. package/src/cli/commands/sync.test.ts +54 -0
  30. package/src/cli/commands/sync.ts +264 -0
  31. package/src/cli/index.ts +1 -0
  32. package/src/crawl/claude-client.test.ts +56 -0
  33. package/src/crawl/claude-client.ts +27 -1
  34. package/src/index.ts +8 -0
  35. package/src/mcp/commands/index.ts +2 -0
  36. package/src/mcp/commands/sync.commands.test.ts +283 -0
  37. package/src/mcp/commands/sync.commands.ts +233 -0
  38. package/src/mcp/server.ts +9 -1
  39. package/src/services/gitignore.service.test.ts +157 -0
  40. package/src/services/gitignore.service.ts +132 -0
  41. package/src/services/store-definition.service.test.ts +440 -0
  42. package/src/services/store-definition.service.ts +198 -0
  43. package/src/services/store.service.test.ts +279 -1
  44. package/src/services/store.service.ts +101 -4
  45. package/src/types/index.ts +18 -0
  46. package/src/types/store-definition.test.ts +492 -0
  47. package/src/types/store-definition.ts +129 -0
  48. package/dist/chunk-CUHYSPRV.js.map +0 -1
  49. package/dist/chunk-DWAIT2OD.js.map +0 -1
@@ -0,0 +1,264 @@
1
+ import { Command } from 'commander';
2
+ import { createServices, destroyServices } from '../../services/index.js';
3
+ import { StoreDefinitionService } from '../../services/store-definition.service.js';
4
+ import {
5
+ isFileStoreDefinition,
6
+ isRepoStoreDefinition,
7
+ isWebStoreDefinition,
8
+ } from '../../types/store-definition.js';
9
+ import type { StoreService } from '../../services/store.service.js';
10
+ import type { StoreDefinition } from '../../types/store-definition.js';
11
+ import type { GlobalOptions } from '../program.js';
12
+
13
+ interface SyncResult {
14
+ created: string[];
15
+ skipped: string[];
16
+ failed: Array<{ name: string; error: string }>;
17
+ orphans: string[];
18
+ pruned: string[];
19
+ dryRun: boolean;
20
+ wouldCreate: string[];
21
+ wouldPrune: string[];
22
+ }
23
+
24
+ /**
25
+ * Create a store from a definition
26
+ */
27
+ async function createStoreFromDefinition(
28
+ def: StoreDefinition,
29
+ defService: StoreDefinitionService,
30
+ storeService: StoreService
31
+ ): Promise<{ success: true } | { success: false; error: string }> {
32
+ try {
33
+ if (isFileStoreDefinition(def)) {
34
+ const resolvedPath = defService.resolvePath(def.path);
35
+ const createResult = await storeService.create(
36
+ {
37
+ name: def.name,
38
+ type: 'file',
39
+ path: resolvedPath,
40
+ description: def.description,
41
+ tags: def.tags,
42
+ },
43
+ { skipDefinitionSync: true }
44
+ );
45
+ if (!createResult.success) {
46
+ return { success: false, error: createResult.error.message };
47
+ }
48
+ return { success: true };
49
+ }
50
+
51
+ if (isRepoStoreDefinition(def)) {
52
+ const createResult = await storeService.create(
53
+ {
54
+ name: def.name,
55
+ type: 'repo',
56
+ url: def.url,
57
+ branch: def.branch,
58
+ depth: def.depth,
59
+ description: def.description,
60
+ tags: def.tags,
61
+ },
62
+ { skipDefinitionSync: true }
63
+ );
64
+ if (!createResult.success) {
65
+ return { success: false, error: createResult.error.message };
66
+ }
67
+ return { success: true };
68
+ }
69
+
70
+ if (isWebStoreDefinition(def)) {
71
+ const createResult = await storeService.create(
72
+ {
73
+ name: def.name,
74
+ type: 'web',
75
+ url: def.url,
76
+ depth: def.depth,
77
+ description: def.description,
78
+ tags: def.tags,
79
+ },
80
+ { skipDefinitionSync: true }
81
+ );
82
+ if (!createResult.success) {
83
+ return { success: false, error: createResult.error.message };
84
+ }
85
+ return { success: true };
86
+ }
87
+
88
+ return { success: false, error: 'Unknown store definition type' };
89
+ } catch (error) {
90
+ return {
91
+ success: false,
92
+ error: error instanceof Error ? error.message : String(error),
93
+ };
94
+ }
95
+ }
96
+
97
+ export function createSyncCommand(getOptions: () => GlobalOptions): Command {
98
+ const sync = new Command('sync').description(
99
+ 'Sync stores from definitions config (bootstrap on fresh clone)'
100
+ );
101
+
102
+ sync
103
+ .option('--dry-run', 'Show what would happen without making changes')
104
+ .option('--prune', 'Remove stores not in definitions')
105
+ .option('--reindex', 'Re-index existing stores after sync')
106
+ .action(async (options: { dryRun?: boolean; prune?: boolean; reindex?: boolean }) => {
107
+ const globalOpts = getOptions();
108
+ const projectRoot = globalOpts.projectRoot ?? process.cwd();
109
+
110
+ const defService = new StoreDefinitionService(projectRoot);
111
+ const services = await createServices(globalOpts.config, globalOpts.dataDir, projectRoot);
112
+
113
+ try {
114
+ const config = await defService.load();
115
+ const existingStores = await services.store.list();
116
+ const existingNames = new Set(existingStores.map((s) => s.name));
117
+ const definedNames = new Set(config.stores.map((d) => d.name));
118
+
119
+ const result: SyncResult = {
120
+ created: [],
121
+ skipped: [],
122
+ failed: [],
123
+ orphans: [],
124
+ pruned: [],
125
+ dryRun: options.dryRun === true,
126
+ wouldCreate: [],
127
+ wouldPrune: [],
128
+ };
129
+
130
+ // Process each definition
131
+ for (const def of config.stores) {
132
+ if (existingNames.has(def.name)) {
133
+ result.skipped.push(def.name);
134
+ continue;
135
+ }
136
+
137
+ if (options.dryRun === true) {
138
+ result.wouldCreate.push(def.name);
139
+ continue;
140
+ }
141
+
142
+ const createResult = await createStoreFromDefinition(def, defService, services.store);
143
+ if (createResult.success) {
144
+ result.created.push(def.name);
145
+ } else {
146
+ result.failed.push({ name: def.name, error: createResult.error });
147
+ }
148
+ }
149
+
150
+ // Find orphans
151
+ for (const store of existingStores) {
152
+ if (!definedNames.has(store.name)) {
153
+ result.orphans.push(store.name);
154
+ }
155
+ }
156
+
157
+ // Prune orphans if requested
158
+ if (options.prune === true && result.orphans.length > 0) {
159
+ if (options.dryRun === true) {
160
+ result.wouldPrune = [...result.orphans];
161
+ } else {
162
+ for (const orphanName of result.orphans) {
163
+ const store = await services.store.getByName(orphanName);
164
+ if (store !== undefined) {
165
+ const deleteResult = await services.store.delete(store.id, {
166
+ skipDefinitionSync: true,
167
+ });
168
+ if (deleteResult.success) {
169
+ result.pruned.push(orphanName);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // Output result
177
+ if (globalOpts.format === 'json') {
178
+ console.log(JSON.stringify(result, null, 2));
179
+ } else {
180
+ printHumanReadable(result, globalOpts.quiet === true);
181
+ }
182
+ } finally {
183
+ await destroyServices(services);
184
+ }
185
+ });
186
+
187
+ return sync;
188
+ }
189
+
190
+ function printHumanReadable(result: SyncResult, quiet: boolean): void {
191
+ if (quiet) {
192
+ // Just print created/pruned store names
193
+ for (const name of result.created) {
194
+ console.log(`created: ${name}`);
195
+ }
196
+ for (const name of result.pruned) {
197
+ console.log(`pruned: ${name}`);
198
+ }
199
+ for (const name of result.wouldCreate) {
200
+ console.log(`would create: ${name}`);
201
+ }
202
+ for (const name of result.wouldPrune) {
203
+ console.log(`would prune: ${name}`);
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (result.dryRun) {
209
+ console.log('\n[DRY RUN] No changes made.\n');
210
+ } else {
211
+ console.log('\nSync completed.\n');
212
+ }
213
+
214
+ if (result.created.length > 0) {
215
+ console.log(`Created (${String(result.created.length)}):`);
216
+ for (const name of result.created) {
217
+ console.log(` + ${name}`);
218
+ }
219
+ }
220
+
221
+ if (result.wouldCreate.length > 0) {
222
+ console.log(`Would create (${String(result.wouldCreate.length)}):`);
223
+ for (const name of result.wouldCreate) {
224
+ console.log(` + ${name}`);
225
+ }
226
+ }
227
+
228
+ if (result.skipped.length > 0) {
229
+ console.log(`Skipped (already exist) (${String(result.skipped.length)}):`);
230
+ for (const name of result.skipped) {
231
+ console.log(` - ${name}`);
232
+ }
233
+ }
234
+
235
+ if (result.failed.length > 0) {
236
+ console.log(`Failed (${String(result.failed.length)}):`);
237
+ for (const { name, error } of result.failed) {
238
+ console.log(` ! ${name}: ${error}`);
239
+ }
240
+ }
241
+
242
+ if (result.orphans.length > 0) {
243
+ console.log(`Orphans (not in definitions) (${String(result.orphans.length)}):`);
244
+ for (const name of result.orphans) {
245
+ console.log(` ? ${name}`);
246
+ }
247
+ }
248
+
249
+ if (result.pruned.length > 0) {
250
+ console.log(`Pruned (${String(result.pruned.length)}):`);
251
+ for (const name of result.pruned) {
252
+ console.log(` x ${name}`);
253
+ }
254
+ }
255
+
256
+ if (result.wouldPrune.length > 0) {
257
+ console.log(`Would prune (${String(result.wouldPrune.length)}):`);
258
+ for (const name of result.wouldPrune) {
259
+ console.log(` x ${name}`);
260
+ }
261
+ }
262
+
263
+ console.log('');
264
+ }
package/src/cli/index.ts CHANGED
@@ -5,3 +5,4 @@ export { createIndexCommand } from './commands/index-cmd.js';
5
5
  export { createServeCommand } from './commands/serve.js';
6
6
  export { createCrawlCommand } from './commands/crawl.js';
7
7
  export { createSetupCommand } from './commands/setup.js';
8
+ export { createSyncCommand } from './commands/sync.js';
@@ -106,6 +106,62 @@ describe('ClaudeClient', () => {
106
106
  expect(result.reasoning).toBe('Found documentation pages');
107
107
  });
108
108
 
109
+ it('should extract structured_output from Claude CLI wrapper format', async () => {
110
+ const promise = client.determineCrawlUrls(
111
+ 'https://example.com',
112
+ '<html>test</html>',
113
+ 'Find all docs'
114
+ );
115
+
116
+ // Claude CLI with --json-schema returns this wrapper format
117
+ setTimeout(() => {
118
+ mockProcess.stdout.emit(
119
+ 'data',
120
+ Buffer.from(
121
+ JSON.stringify({
122
+ type: 'result',
123
+ subtype: 'success',
124
+ result: '',
125
+ structured_output: {
126
+ urls: ['https://example.com/page1', 'https://example.com/page2'],
127
+ reasoning: 'Found documentation pages',
128
+ },
129
+ })
130
+ )
131
+ );
132
+ mockProcess.emit('close', 0);
133
+ }, 10);
134
+
135
+ const result = await promise;
136
+ expect(result.urls).toEqual(['https://example.com/page1', 'https://example.com/page2']);
137
+ expect(result.reasoning).toBe('Found documentation pages');
138
+ });
139
+
140
+ it('should fall back to raw response when structured_output is not an object', async () => {
141
+ const promise = client.determineCrawlUrls(
142
+ 'https://example.com',
143
+ '<html>test</html>',
144
+ 'Find all docs'
145
+ );
146
+
147
+ // When structured_output is not an object, use the raw response
148
+ // (which will fail validation if it doesn't have urls/reasoning)
149
+ setTimeout(() => {
150
+ mockProcess.stdout.emit(
151
+ 'data',
152
+ Buffer.from(
153
+ JSON.stringify({
154
+ type: 'result',
155
+ structured_output: 'not an object',
156
+ })
157
+ )
158
+ );
159
+ mockProcess.emit('close', 0);
160
+ }, 10);
161
+
162
+ await expect(promise).rejects.toThrow('invalid crawl strategy');
163
+ });
164
+
109
165
  it('should call spawn with correct arguments for determineCrawlUrls', async () => {
110
166
  const promise = client.determineCrawlUrls(
111
167
  'https://example.com',
@@ -96,7 +96,11 @@ Return only URLs that are relevant to the instruction. If the instruction mentio
96
96
 
97
97
  try {
98
98
  const result = await this.callClaude(prompt, CRAWL_STRATEGY_SCHEMA);
99
- const parsed: unknown = JSON.parse(result);
99
+ const rawParsed: unknown = JSON.parse(result);
100
+
101
+ // Claude CLI with --json-schema returns wrapper: {type, result, structured_output: {...}}
102
+ // Extract structured_output if present, otherwise use raw response
103
+ const parsed = this.extractStructuredOutput(rawParsed);
100
104
 
101
105
  // Validate and narrow type
102
106
  if (
@@ -232,4 +236,26 @@ ${this.truncateMarkdown(markdown, 100000)}`;
232
236
 
233
237
  return `${markdown.substring(0, maxLength)}\n\n[... content truncated ...]`;
234
238
  }
239
+
240
+ /**
241
+ * Type guard to check if value is a record (plain object)
242
+ */
243
+ private isRecord(value: unknown): value is Record<string, unknown> {
244
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
245
+ }
246
+
247
+ /**
248
+ * Extract structured_output from Claude CLI wrapper format if present.
249
+ * Claude CLI with --json-schema returns: {type, result, structured_output: {...}}
250
+ * This method extracts the inner structured_output, or returns the raw value if not wrapped.
251
+ */
252
+ private extractStructuredOutput(rawParsed: unknown): unknown {
253
+ if (this.isRecord(rawParsed) && 'structured_output' in rawParsed) {
254
+ const structuredOutput = rawParsed['structured_output'];
255
+ if (typeof structuredOutput === 'object') {
256
+ return structuredOutput;
257
+ }
258
+ }
259
+ return rawParsed;
260
+ }
235
261
  }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { Command } from 'commander';
6
+ import { AdapterRegistry } from './analysis/adapter-registry.js';
7
+ import { ZilAdapter } from './analysis/zil/index.js';
6
8
  import { createCrawlCommand } from './cli/commands/crawl.js';
7
9
  import { createIndexCommand } from './cli/commands/index-cmd.js';
8
10
  import { createMCPCommand } from './cli/commands/mcp.js';
@@ -16,8 +18,13 @@ import { createSearchCommand } from './cli/commands/search.js';
16
18
  import { createServeCommand } from './cli/commands/serve.js';
17
19
  import { createSetupCommand } from './cli/commands/setup.js';
18
20
  import { createStoreCommand } from './cli/commands/store.js';
21
+ import { createSyncCommand } from './cli/commands/sync.js';
19
22
  import { createProgram, getGlobalOptions } from './cli/program.js';
20
23
 
24
+ // Register built-in language adapters
25
+ const registry = AdapterRegistry.getInstance();
26
+ registry.register(new ZilAdapter());
27
+
21
28
  // Default paths
22
29
  const DEFAULT_DATA_DIR = join(homedir(), '.bluera', 'bluera-knowledge', 'data');
23
30
  const DEFAULT_CONFIG = join(homedir(), '.bluera', 'bluera-knowledge', 'config.json');
@@ -105,6 +112,7 @@ program.addCommand(createIndexCommand(() => getGlobalOptions(program)));
105
112
  program.addCommand(createServeCommand(() => getGlobalOptions(program)));
106
113
  program.addCommand(createCrawlCommand(() => getGlobalOptions(program)));
107
114
  program.addCommand(createSetupCommand(() => getGlobalOptions(program)));
115
+ program.addCommand(createSyncCommand(() => getGlobalOptions(program)));
108
116
  program.addCommand(createMCPCommand(() => getGlobalOptions(program)));
109
117
 
110
118
  // Show comprehensive help when no arguments provided
@@ -9,11 +9,13 @@ import { jobCommands } from './job.commands.js';
9
9
  import { metaCommands } from './meta.commands.js';
10
10
  import { commandRegistry } from './registry.js';
11
11
  import { storeCommands } from './store.commands.js';
12
+ import { syncCommands } from './sync.commands.js';
12
13
 
13
14
  // Register all commands
14
15
  commandRegistry.registerAll(storeCommands);
15
16
  commandRegistry.registerAll(jobCommands);
16
17
  commandRegistry.registerAll(metaCommands);
18
+ commandRegistry.registerAll(syncCommands);
17
19
 
18
20
  // Re-export for convenience
19
21
  export { commandRegistry, executeCommand, generateHelp } from './registry.js';
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { rm, mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { syncCommands, handleStoresSync } from './sync.commands.js';
7
+ import { StoreService } from '../../services/store.service.js';
8
+ import { StoreDefinitionService } from '../../services/store-definition.service.js';
9
+ import type { HandlerContext } from '../types.js';
10
+ import type { ServiceContainer } from '../../services/index.js';
11
+ import type { StoreDefinitionsConfig } from '../../types/store-definition.js';
12
+
13
+ /**
14
+ * Create a minimal mock service container for testing
15
+ */
16
+ function createMockServices(storeService: StoreService): ServiceContainer {
17
+ return {
18
+ store: storeService,
19
+ // Other services not needed for sync tests
20
+ config: {} as ServiceContainer['config'],
21
+ search: {} as ServiceContainer['search'],
22
+ index: {} as ServiceContainer['index'],
23
+ lance: {} as ServiceContainer['lance'],
24
+ embeddings: {} as ServiceContainer['embeddings'],
25
+ codeGraph: {} as ServiceContainer['codeGraph'],
26
+ pythonBridge: {} as ServiceContainer['pythonBridge'],
27
+ };
28
+ }
29
+
30
+ describe('sync.commands', () => {
31
+ describe('command definition', () => {
32
+ it('exports stores:sync command', () => {
33
+ const syncCmd = syncCommands.find((c) => c.name === 'stores:sync');
34
+ expect(syncCmd).toBeDefined();
35
+ expect(syncCmd?.description).toContain('Sync');
36
+ });
37
+
38
+ it('has correct args schema', () => {
39
+ const syncCmd = syncCommands.find((c) => c.name === 'stores:sync');
40
+ expect(syncCmd?.argsSchema).toBeDefined();
41
+
42
+ // Valid empty args
43
+ const result1 = syncCmd?.argsSchema?.safeParse({});
44
+ expect(result1?.success).toBe(true);
45
+
46
+ // Valid with options
47
+ const result2 = syncCmd?.argsSchema?.safeParse({
48
+ reindex: true,
49
+ prune: true,
50
+ dryRun: true,
51
+ });
52
+ expect(result2?.success).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('handleStoresSync', () => {
57
+ let projectRoot: string;
58
+ let dataDir: string;
59
+ let storeService: StoreService;
60
+ let defService: StoreDefinitionService;
61
+ let context: HandlerContext;
62
+
63
+ beforeEach(async () => {
64
+ projectRoot = await mkdtemp(join(tmpdir(), 'sync-test-'));
65
+ dataDir = join(projectRoot, '.bluera/bluera-knowledge/data');
66
+ defService = new StoreDefinitionService(projectRoot);
67
+ storeService = new StoreService(dataDir, { definitionService: defService });
68
+ await storeService.initialize();
69
+
70
+ context = {
71
+ services: createMockServices(storeService),
72
+ options: { projectRoot },
73
+ };
74
+ });
75
+
76
+ afterEach(async () => {
77
+ await rm(projectRoot, { recursive: true, force: true });
78
+ });
79
+
80
+ describe('creates missing stores', () => {
81
+ it('creates file store from definition', async () => {
82
+ // Create a directory to reference
83
+ const docsDir = join(projectRoot, 'docs');
84
+ await mkdir(docsDir, { recursive: true });
85
+
86
+ // Add definition manually (simulating config from git)
87
+ await defService.addDefinition({
88
+ type: 'file',
89
+ name: 'my-docs',
90
+ path: './docs',
91
+ description: 'Documentation',
92
+ });
93
+
94
+ const result = await handleStoresSync({}, context);
95
+ const response = JSON.parse(result.content[0].text);
96
+
97
+ expect(response.created).toContain('my-docs');
98
+ expect(response.skipped).toHaveLength(0);
99
+ expect(response.failed).toHaveLength(0);
100
+
101
+ // Verify store was created
102
+ const store = await storeService.getByName('my-docs');
103
+ expect(store).toBeDefined();
104
+ expect(store?.type).toBe('file');
105
+ });
106
+
107
+ it('creates web store from definition', async () => {
108
+ // Add web store definition
109
+ await defService.addDefinition({
110
+ type: 'web',
111
+ name: 'api-docs',
112
+ url: 'https://example.com/docs',
113
+ depth: 2,
114
+ });
115
+
116
+ const result = await handleStoresSync({}, context);
117
+ const response = JSON.parse(result.content[0].text);
118
+
119
+ expect(response.created).toContain('api-docs');
120
+
121
+ const store = await storeService.getByName('api-docs');
122
+ expect(store).toBeDefined();
123
+ expect(store?.type).toBe('web');
124
+ });
125
+ });
126
+
127
+ describe('skips existing stores', () => {
128
+ it('skips store that already exists', async () => {
129
+ const docsDir = join(projectRoot, 'docs');
130
+ await mkdir(docsDir, { recursive: true });
131
+
132
+ // Create store first (this auto-adds the definition via the integration)
133
+ await storeService.create({
134
+ name: 'existing-docs',
135
+ type: 'file',
136
+ path: docsDir,
137
+ });
138
+
139
+ // Definition was auto-added, so sync should skip this store
140
+ const result = await handleStoresSync({}, context);
141
+ const response = JSON.parse(result.content[0].text);
142
+
143
+ expect(response.skipped).toContain('existing-docs');
144
+ expect(response.created).toHaveLength(0);
145
+ });
146
+ });
147
+
148
+ describe('reports orphans', () => {
149
+ it('reports stores not in definitions', async () => {
150
+ const docsDir = join(projectRoot, 'docs');
151
+ await mkdir(docsDir, { recursive: true });
152
+
153
+ // Create store without definition (using skipDefinitionSync)
154
+ await storeService.create(
155
+ {
156
+ name: 'orphan-store',
157
+ type: 'file',
158
+ path: docsDir,
159
+ },
160
+ { skipDefinitionSync: true }
161
+ );
162
+
163
+ const result = await handleStoresSync({}, context);
164
+ const response = JSON.parse(result.content[0].text);
165
+
166
+ expect(response.orphans).toContain('orphan-store');
167
+ });
168
+ });
169
+
170
+ describe('dry run mode', () => {
171
+ it('does not create stores in dry run mode', async () => {
172
+ const docsDir = join(projectRoot, 'docs');
173
+ await mkdir(docsDir, { recursive: true });
174
+
175
+ await defService.addDefinition({
176
+ type: 'file',
177
+ name: 'dry-run-store',
178
+ path: './docs',
179
+ });
180
+
181
+ const result = await handleStoresSync({ dryRun: true }, context);
182
+ const response = JSON.parse(result.content[0].text);
183
+
184
+ expect(response.dryRun).toBe(true);
185
+ expect(response.wouldCreate).toContain('dry-run-store');
186
+
187
+ // Store should NOT exist
188
+ const store = await storeService.getByName('dry-run-store');
189
+ expect(store).toBeUndefined();
190
+ });
191
+ });
192
+
193
+ describe('prune mode', () => {
194
+ it('removes orphan stores when prune is true', async () => {
195
+ const docsDir = join(projectRoot, 'docs');
196
+ await mkdir(docsDir, { recursive: true });
197
+
198
+ // Create orphan store
199
+ await storeService.create(
200
+ {
201
+ name: 'to-prune',
202
+ type: 'file',
203
+ path: docsDir,
204
+ },
205
+ { skipDefinitionSync: true }
206
+ );
207
+
208
+ const result = await handleStoresSync({ prune: true }, context);
209
+ const response = JSON.parse(result.content[0].text);
210
+
211
+ expect(response.pruned).toContain('to-prune');
212
+
213
+ // Store should be deleted
214
+ const store = await storeService.getByName('to-prune');
215
+ expect(store).toBeUndefined();
216
+ });
217
+
218
+ it('does not prune in dry run mode', async () => {
219
+ const docsDir = join(projectRoot, 'docs');
220
+ await mkdir(docsDir, { recursive: true });
221
+
222
+ await storeService.create(
223
+ {
224
+ name: 'keep-me',
225
+ type: 'file',
226
+ path: docsDir,
227
+ },
228
+ { skipDefinitionSync: true }
229
+ );
230
+
231
+ const result = await handleStoresSync({ prune: true, dryRun: true }, context);
232
+ const response = JSON.parse(result.content[0].text);
233
+
234
+ expect(response.wouldPrune).toContain('keep-me');
235
+
236
+ // Store should still exist
237
+ const store = await storeService.getByName('keep-me');
238
+ expect(store).toBeDefined();
239
+ });
240
+ });
241
+
242
+ describe('error handling', () => {
243
+ it('continues on error and reports failures', async () => {
244
+ // Add definition for non-existent directory
245
+ await defService.addDefinition({
246
+ type: 'file',
247
+ name: 'bad-store',
248
+ path: './nonexistent',
249
+ });
250
+
251
+ // Also add a valid definition
252
+ const docsDir = join(projectRoot, 'docs');
253
+ await mkdir(docsDir, { recursive: true });
254
+ await defService.addDefinition({
255
+ type: 'file',
256
+ name: 'good-store',
257
+ path: './docs',
258
+ });
259
+
260
+ const result = await handleStoresSync({}, context);
261
+ const response = JSON.parse(result.content[0].text);
262
+
263
+ // Should have one failure and one success
264
+ expect(response.failed).toHaveLength(1);
265
+ expect(response.failed[0].name).toBe('bad-store');
266
+ expect(response.failed[0].error).toBeDefined();
267
+ expect(response.created).toContain('good-store');
268
+ });
269
+ });
270
+
271
+ describe('empty config', () => {
272
+ it('handles empty definitions gracefully', async () => {
273
+ const result = await handleStoresSync({}, context);
274
+ const response = JSON.parse(result.content[0].text);
275
+
276
+ expect(response.created).toHaveLength(0);
277
+ expect(response.skipped).toHaveLength(0);
278
+ expect(response.failed).toHaveLength(0);
279
+ expect(response.orphans).toHaveLength(0);
280
+ });
281
+ });
282
+ });
283
+ });