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,440 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { StoreDefinitionService } from './store-definition.service.js';
3
+ import { rm, mkdtemp, writeFile, readFile, mkdir, access } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+ import type { StoreDefinitionsConfig, FileStoreDefinition } from '../types/store-definition.js';
7
+
8
+ /**
9
+ * Helper to create isolated test context.
10
+ * Each test creates its own temp directory to ensure complete isolation.
11
+ */
12
+ async function createTestContext(): Promise<{
13
+ service: StoreDefinitionService;
14
+ tempDir: string;
15
+ cleanup: () => Promise<void>;
16
+ }> {
17
+ const tempDir = await mkdtemp(join(tmpdir(), 'store-def-test-'));
18
+ const service = new StoreDefinitionService(tempDir);
19
+ return {
20
+ service,
21
+ tempDir,
22
+ cleanup: async () => {
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ },
25
+ };
26
+ }
27
+
28
+ describe('StoreDefinitionService', () => {
29
+ describe('load', () => {
30
+ it('returns empty config when file does not exist', async () => {
31
+ const ctx = await createTestContext();
32
+ try {
33
+ const config = await ctx.service.load();
34
+ expect(config.version).toBe(1);
35
+ expect(config.stores).toEqual([]);
36
+ } finally {
37
+ await ctx.cleanup();
38
+ }
39
+ });
40
+
41
+ it('loads existing config file', async () => {
42
+ const ctx = await createTestContext();
43
+ try {
44
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
45
+ await mkdir(dirname(configPath), { recursive: true });
46
+ const existingConfig: StoreDefinitionsConfig = {
47
+ version: 1,
48
+ stores: [{ type: 'file', name: 'test', path: './test' }],
49
+ };
50
+ await writeFile(configPath, JSON.stringify(existingConfig));
51
+
52
+ const config = await ctx.service.load();
53
+ expect(config.stores).toHaveLength(1);
54
+ expect(config.stores[0].name).toBe('test');
55
+ } finally {
56
+ await ctx.cleanup();
57
+ }
58
+ });
59
+
60
+ it('throws on invalid JSON (fail fast per CLAUDE.md)', async () => {
61
+ const ctx = await createTestContext();
62
+ try {
63
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
64
+ await mkdir(dirname(configPath), { recursive: true });
65
+ await writeFile(configPath, '{invalid json');
66
+
67
+ await expect(ctx.service.load()).rejects.toThrow(/parse|JSON/i);
68
+ } finally {
69
+ await ctx.cleanup();
70
+ }
71
+ });
72
+
73
+ it('throws on invalid schema (fail fast per CLAUDE.md)', async () => {
74
+ const ctx = await createTestContext();
75
+ try {
76
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
77
+ await mkdir(dirname(configPath), { recursive: true });
78
+ await writeFile(configPath, JSON.stringify({ version: 1, stores: [{ type: 'invalid' }] }));
79
+
80
+ await expect(ctx.service.load()).rejects.toThrow();
81
+ } finally {
82
+ await ctx.cleanup();
83
+ }
84
+ });
85
+
86
+ it('throws on wrong version (fail fast per CLAUDE.md)', async () => {
87
+ const ctx = await createTestContext();
88
+ try {
89
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
90
+ await mkdir(dirname(configPath), { recursive: true });
91
+ await writeFile(configPath, JSON.stringify({ version: 99, stores: [] }));
92
+
93
+ await expect(ctx.service.load()).rejects.toThrow();
94
+ } finally {
95
+ await ctx.cleanup();
96
+ }
97
+ });
98
+
99
+ it('caches loaded config', async () => {
100
+ const ctx = await createTestContext();
101
+ try {
102
+ const config1 = await ctx.service.load();
103
+ const config2 = await ctx.service.load();
104
+ expect(config1).toBe(config2); // Same reference
105
+ } finally {
106
+ await ctx.cleanup();
107
+ }
108
+ });
109
+ });
110
+
111
+ describe('save', () => {
112
+ it('creates directory if not exists', async () => {
113
+ const ctx = await createTestContext();
114
+ try {
115
+ const config: StoreDefinitionsConfig = { version: 1, stores: [] };
116
+ await ctx.service.save(config);
117
+
118
+ const configPath = ctx.service.getConfigPath();
119
+ await expect(access(configPath)).resolves.toBeUndefined();
120
+ } finally {
121
+ await ctx.cleanup();
122
+ }
123
+ });
124
+
125
+ it('writes formatted JSON', async () => {
126
+ const ctx = await createTestContext();
127
+ try {
128
+ const config: StoreDefinitionsConfig = {
129
+ version: 1,
130
+ stores: [{ type: 'file', name: 'test', path: './test' }],
131
+ };
132
+ await ctx.service.save(config);
133
+
134
+ const content = await readFile(ctx.service.getConfigPath(), 'utf-8');
135
+ expect(content).toContain('\n'); // Formatted with newlines
136
+ expect(content).toContain('"version": 1');
137
+ } finally {
138
+ await ctx.cleanup();
139
+ }
140
+ });
141
+
142
+ it('updates cache after save', async () => {
143
+ const ctx = await createTestContext();
144
+ try {
145
+ const config: StoreDefinitionsConfig = {
146
+ version: 1,
147
+ stores: [{ type: 'file', name: 'test', path: './test' }],
148
+ };
149
+ await ctx.service.save(config);
150
+
151
+ const loaded = await ctx.service.load();
152
+ expect(loaded.stores).toHaveLength(1);
153
+ } finally {
154
+ await ctx.cleanup();
155
+ }
156
+ });
157
+ });
158
+
159
+ describe('addDefinition', () => {
160
+ it('adds definition to empty config', async () => {
161
+ const ctx = await createTestContext();
162
+ try {
163
+ const definition: FileStoreDefinition = {
164
+ type: 'file',
165
+ name: 'docs',
166
+ path: './docs',
167
+ };
168
+ await ctx.service.addDefinition(definition);
169
+
170
+ const config = await ctx.service.load();
171
+ expect(config.stores).toHaveLength(1);
172
+ expect(config.stores[0].name).toBe('docs');
173
+ } finally {
174
+ await ctx.cleanup();
175
+ }
176
+ });
177
+
178
+ it('adds definition to existing config', async () => {
179
+ const ctx = await createTestContext();
180
+ try {
181
+ // Pre-populate
182
+ await ctx.service.save({
183
+ version: 1,
184
+ stores: [{ type: 'file', name: 'existing', path: './existing' }],
185
+ });
186
+ // Clear cache to force reload
187
+ ctx.service.clearCache();
188
+
189
+ // Add new
190
+ await ctx.service.addDefinition({ type: 'file', name: 'new', path: './new' });
191
+
192
+ const config = await ctx.service.load();
193
+ expect(config.stores).toHaveLength(2);
194
+ } finally {
195
+ await ctx.cleanup();
196
+ }
197
+ });
198
+
199
+ it('throws when adding duplicate name', async () => {
200
+ const ctx = await createTestContext();
201
+ try {
202
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
203
+
204
+ await expect(
205
+ ctx.service.addDefinition({ type: 'file', name: 'docs', path: './other' })
206
+ ).rejects.toThrow(/already exists/i);
207
+ } finally {
208
+ await ctx.cleanup();
209
+ }
210
+ });
211
+
212
+ it('persists to file', async () => {
213
+ const ctx = await createTestContext();
214
+ try {
215
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
216
+
217
+ // Create fresh service (no cache)
218
+ const freshService = new StoreDefinitionService(ctx.tempDir);
219
+ const config = await freshService.load();
220
+ expect(config.stores).toHaveLength(1);
221
+ } finally {
222
+ await ctx.cleanup();
223
+ }
224
+ });
225
+ });
226
+
227
+ describe('removeDefinition', () => {
228
+ it('removes existing definition', async () => {
229
+ const ctx = await createTestContext();
230
+ try {
231
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
232
+ await ctx.service.addDefinition({ type: 'file', name: 'other', path: './other' });
233
+
234
+ const removed = await ctx.service.removeDefinition('docs');
235
+ expect(removed).toBe(true);
236
+
237
+ const config = await ctx.service.load();
238
+ expect(config.stores).toHaveLength(1);
239
+ expect(config.stores[0].name).toBe('other');
240
+ } finally {
241
+ await ctx.cleanup();
242
+ }
243
+ });
244
+
245
+ it('returns false for non-existent name', async () => {
246
+ const ctx = await createTestContext();
247
+ try {
248
+ const removed = await ctx.service.removeDefinition('nonexistent');
249
+ expect(removed).toBe(false);
250
+ } finally {
251
+ await ctx.cleanup();
252
+ }
253
+ });
254
+
255
+ it('persists removal to file', async () => {
256
+ const ctx = await createTestContext();
257
+ try {
258
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
259
+ await ctx.service.removeDefinition('docs');
260
+
261
+ // Create fresh service
262
+ const freshService = new StoreDefinitionService(ctx.tempDir);
263
+ const config = await freshService.load();
264
+ expect(config.stores).toHaveLength(0);
265
+ } finally {
266
+ await ctx.cleanup();
267
+ }
268
+ });
269
+ });
270
+
271
+ describe('updateDefinition', () => {
272
+ it('updates existing definition', async () => {
273
+ const ctx = await createTestContext();
274
+ try {
275
+ await ctx.service.addDefinition({
276
+ type: 'file',
277
+ name: 'docs',
278
+ path: './docs',
279
+ description: 'old',
280
+ });
281
+
282
+ await ctx.service.updateDefinition('docs', { description: 'new description' });
283
+
284
+ const config = await ctx.service.load();
285
+ expect(config.stores[0].description).toBe('new description');
286
+ } finally {
287
+ await ctx.cleanup();
288
+ }
289
+ });
290
+
291
+ it('preserves unchanged fields', async () => {
292
+ const ctx = await createTestContext();
293
+ try {
294
+ await ctx.service.addDefinition({
295
+ type: 'file',
296
+ name: 'docs',
297
+ path: './docs',
298
+ description: 'desc',
299
+ tags: ['tag1'],
300
+ });
301
+
302
+ await ctx.service.updateDefinition('docs', { description: 'updated' });
303
+
304
+ const config = await ctx.service.load();
305
+ const store = config.stores[0] as FileStoreDefinition;
306
+ expect(store.path).toBe('./docs');
307
+ expect(store.tags).toEqual(['tag1']);
308
+ } finally {
309
+ await ctx.cleanup();
310
+ }
311
+ });
312
+
313
+ it('throws for non-existent definition', async () => {
314
+ const ctx = await createTestContext();
315
+ try {
316
+ await expect(
317
+ ctx.service.updateDefinition('nonexistent', { description: 'x' })
318
+ ).rejects.toThrow(/not found/i);
319
+ } finally {
320
+ await ctx.cleanup();
321
+ }
322
+ });
323
+
324
+ it('persists update to file', async () => {
325
+ const ctx = await createTestContext();
326
+ try {
327
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
328
+ await ctx.service.updateDefinition('docs', { description: 'updated' });
329
+
330
+ const freshService = new StoreDefinitionService(ctx.tempDir);
331
+ const config = await freshService.load();
332
+ expect(config.stores[0].description).toBe('updated');
333
+ } finally {
334
+ await ctx.cleanup();
335
+ }
336
+ });
337
+ });
338
+
339
+ describe('getByName', () => {
340
+ it('returns definition by name', async () => {
341
+ const ctx = await createTestContext();
342
+ try {
343
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
344
+
345
+ const def = await ctx.service.getByName('docs');
346
+ expect(def).toBeDefined();
347
+ expect(def?.name).toBe('docs');
348
+ } finally {
349
+ await ctx.cleanup();
350
+ }
351
+ });
352
+
353
+ it('returns undefined for non-existent name', async () => {
354
+ const ctx = await createTestContext();
355
+ try {
356
+ const def = await ctx.service.getByName('nonexistent');
357
+ expect(def).toBeUndefined();
358
+ } finally {
359
+ await ctx.cleanup();
360
+ }
361
+ });
362
+ });
363
+
364
+ describe('resolvePath', () => {
365
+ it('resolves relative paths against project root', async () => {
366
+ const ctx = await createTestContext();
367
+ try {
368
+ const resolved = ctx.service.resolvePath('./docs');
369
+ expect(resolved).toBe(join(ctx.tempDir, 'docs'));
370
+ } finally {
371
+ await ctx.cleanup();
372
+ }
373
+ });
374
+
375
+ it('resolves paths starting with ./ against project root', async () => {
376
+ const ctx = await createTestContext();
377
+ try {
378
+ const resolved = ctx.service.resolvePath('./src/utils');
379
+ expect(resolved).toBe(join(ctx.tempDir, 'src/utils'));
380
+ } finally {
381
+ await ctx.cleanup();
382
+ }
383
+ });
384
+
385
+ it('resolves paths without leading ./ against project root', async () => {
386
+ const ctx = await createTestContext();
387
+ try {
388
+ const resolved = ctx.service.resolvePath('docs');
389
+ expect(resolved).toBe(join(ctx.tempDir, 'docs'));
390
+ } finally {
391
+ await ctx.cleanup();
392
+ }
393
+ });
394
+
395
+ it('keeps absolute paths as-is', async () => {
396
+ const ctx = await createTestContext();
397
+ try {
398
+ const resolved = ctx.service.resolvePath('/absolute/path');
399
+ expect(resolved).toBe('/absolute/path');
400
+ } finally {
401
+ await ctx.cleanup();
402
+ }
403
+ });
404
+ });
405
+
406
+ describe('getConfigPath', () => {
407
+ it('returns correct config path', async () => {
408
+ const ctx = await createTestContext();
409
+ try {
410
+ const configPath = ctx.service.getConfigPath();
411
+ expect(configPath).toBe(join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json'));
412
+ } finally {
413
+ await ctx.cleanup();
414
+ }
415
+ });
416
+ });
417
+
418
+ describe('hasDefinitions', () => {
419
+ it('returns false when no definitions', async () => {
420
+ const ctx = await createTestContext();
421
+ try {
422
+ const has = await ctx.service.hasDefinitions();
423
+ expect(has).toBe(false);
424
+ } finally {
425
+ await ctx.cleanup();
426
+ }
427
+ });
428
+
429
+ it('returns true when definitions exist', async () => {
430
+ const ctx = await createTestContext();
431
+ try {
432
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
433
+ const has = await ctx.service.hasDefinitions();
434
+ expect(has).toBe(true);
435
+ } finally {
436
+ await ctx.cleanup();
437
+ }
438
+ });
439
+ });
440
+ });
@@ -0,0 +1,198 @@
1
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
2
+ import { dirname, resolve, isAbsolute, join } from 'node:path';
3
+ import { ProjectRootService } from './project-root.service.js';
4
+ import {
5
+ StoreDefinitionsConfigSchema,
6
+ DEFAULT_STORE_DEFINITIONS_CONFIG,
7
+ } from '../types/store-definition.js';
8
+ import type { StoreDefinitionsConfig, StoreDefinition } from '../types/store-definition.js';
9
+
10
+ /**
11
+ * Check if a file exists
12
+ */
13
+ async function fileExists(path: string): Promise<boolean> {
14
+ try {
15
+ await access(path);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Service for managing git-committable store definitions.
24
+ *
25
+ * Store definitions are saved to `.bluera/bluera-knowledge/stores.config.json`
26
+ * within the project root. This file is designed to be committed to version
27
+ * control, allowing teams to share store configurations.
28
+ *
29
+ * The actual store data (vector embeddings, cloned repos) lives in the data
30
+ * directory and should be gitignored.
31
+ */
32
+ export class StoreDefinitionService {
33
+ private readonly configPath: string;
34
+ private readonly projectRoot: string;
35
+ private config: StoreDefinitionsConfig | null = null;
36
+
37
+ constructor(projectRoot?: string) {
38
+ this.projectRoot = projectRoot ?? ProjectRootService.resolve();
39
+ this.configPath = join(this.projectRoot, '.bluera/bluera-knowledge/stores.config.json');
40
+ }
41
+
42
+ /**
43
+ * Load store definitions from config file.
44
+ * Returns empty config if file doesn't exist.
45
+ * Throws on parse/validation errors (fail fast per CLAUDE.md).
46
+ */
47
+ async load(): Promise<StoreDefinitionsConfig> {
48
+ if (this.config !== null) {
49
+ return this.config;
50
+ }
51
+
52
+ const exists = await fileExists(this.configPath);
53
+ if (!exists) {
54
+ // Deep clone to avoid mutating the shared default
55
+ this.config = {
56
+ ...DEFAULT_STORE_DEFINITIONS_CONFIG,
57
+ stores: [...DEFAULT_STORE_DEFINITIONS_CONFIG.stores],
58
+ };
59
+ return this.config;
60
+ }
61
+
62
+ const content = await readFile(this.configPath, 'utf-8');
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(content);
66
+ } catch (error) {
67
+ throw new Error(
68
+ `Failed to parse store definitions at ${this.configPath}: ${
69
+ error instanceof Error ? error.message : String(error)
70
+ }`
71
+ );
72
+ }
73
+
74
+ const result = StoreDefinitionsConfigSchema.safeParse(parsed);
75
+ if (!result.success) {
76
+ throw new Error(`Invalid store definitions at ${this.configPath}: ${result.error.message}`);
77
+ }
78
+
79
+ this.config = result.data;
80
+ return this.config;
81
+ }
82
+
83
+ /**
84
+ * Save store definitions to config file.
85
+ */
86
+ async save(config: StoreDefinitionsConfig): Promise<void> {
87
+ await mkdir(dirname(this.configPath), { recursive: true });
88
+ await writeFile(this.configPath, JSON.stringify(config, null, 2));
89
+ this.config = config;
90
+ }
91
+
92
+ /**
93
+ * Add a store definition.
94
+ * Throws if a definition with the same name already exists.
95
+ */
96
+ async addDefinition(definition: StoreDefinition): Promise<void> {
97
+ const config = await this.load();
98
+ const existing = config.stores.find((s) => s.name === definition.name);
99
+ if (existing !== undefined) {
100
+ throw new Error(`Store definition "${definition.name}" already exists`);
101
+ }
102
+ config.stores.push(definition);
103
+ await this.save(config);
104
+ }
105
+
106
+ /**
107
+ * Remove a store definition by name.
108
+ * Returns true if removed, false if not found.
109
+ */
110
+ async removeDefinition(name: string): Promise<boolean> {
111
+ const config = await this.load();
112
+ const index = config.stores.findIndex((s) => s.name === name);
113
+ if (index === -1) {
114
+ return false;
115
+ }
116
+ config.stores.splice(index, 1);
117
+ await this.save(config);
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Update an existing store definition.
123
+ * Only updates the provided fields, preserving others.
124
+ * Throws if definition not found.
125
+ */
126
+ async updateDefinition(
127
+ name: string,
128
+ updates: { description?: string; tags?: string[] }
129
+ ): Promise<void> {
130
+ const config = await this.load();
131
+ const index = config.stores.findIndex((s) => s.name === name);
132
+ if (index === -1) {
133
+ throw new Error(`Store definition "${name}" not found`);
134
+ }
135
+
136
+ // Merge updates while preserving type safety
137
+ // We only allow updating common optional fields (description, tags)
138
+ const existing = config.stores[index];
139
+ if (existing === undefined) {
140
+ throw new Error(`Store definition "${name}" not found at index ${String(index)}`);
141
+ }
142
+ if (updates.description !== undefined) {
143
+ existing.description = updates.description;
144
+ }
145
+ if (updates.tags !== undefined) {
146
+ existing.tags = updates.tags;
147
+ }
148
+ await this.save(config);
149
+ }
150
+
151
+ /**
152
+ * Get a store definition by name.
153
+ * Returns undefined if not found.
154
+ */
155
+ async getByName(name: string): Promise<StoreDefinition | undefined> {
156
+ const config = await this.load();
157
+ return config.stores.find((s) => s.name === name);
158
+ }
159
+
160
+ /**
161
+ * Check if any definitions exist.
162
+ */
163
+ async hasDefinitions(): Promise<boolean> {
164
+ const config = await this.load();
165
+ return config.stores.length > 0;
166
+ }
167
+
168
+ /**
169
+ * Resolve a file store path relative to project root.
170
+ */
171
+ resolvePath(path: string): string {
172
+ if (isAbsolute(path)) {
173
+ return path;
174
+ }
175
+ return resolve(this.projectRoot, path);
176
+ }
177
+
178
+ /**
179
+ * Get the config file path.
180
+ */
181
+ getConfigPath(): string {
182
+ return this.configPath;
183
+ }
184
+
185
+ /**
186
+ * Get the project root.
187
+ */
188
+ getProjectRoot(): string {
189
+ return this.projectRoot;
190
+ }
191
+
192
+ /**
193
+ * Clear the cached config (useful for testing).
194
+ */
195
+ clearCache(): void {
196
+ this.config = null;
197
+ }
198
+ }