@projitive/mcp 2.0.4 → 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.
@@ -2,13 +2,18 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { discoverProjects, discoverProjectsAcrossRoots, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoots, resolveScanDepth, toProjectPath, registerProjectTools } from './project.js';
5
+ import { discoverProjects, discoverProjectsAcrossRoots, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoots, resolveScanRoot, resolveScanDepth, toProjectPath, registerProjectTools } from './project.js';
6
6
  const tempPaths = [];
7
7
  async function createTempDir() {
8
8
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-mcp-test-'));
9
9
  tempPaths.push(dir);
10
10
  return dir;
11
11
  }
12
+ function getProjectToolHandler(mockServer, toolName) {
13
+ const call = mockServer.registerTool.mock.calls.find((entry) => entry[0] === toolName);
14
+ expect(call).toBeTruthy();
15
+ return call?.[2];
16
+ }
12
17
  afterEach(async () => {
13
18
  await Promise.all(tempPaths.splice(0).map(async (dir) => {
14
19
  await fs.rm(dir, { recursive: true, force: true });
@@ -82,6 +87,17 @@ describe('projitive module', () => {
82
87
  await fs.mkdir(deepDir, { recursive: true });
83
88
  await expect(resolveGovernanceDir(deepDir)).rejects.toThrow('No .projitive marker found');
84
89
  });
90
+ it('throws when multiple non-default governance roots exist under same parent', async () => {
91
+ const root = await createTempDir();
92
+ const childDir = path.join(root, 'child');
93
+ const gov1 = path.join(childDir, 'governance-a');
94
+ const gov2 = path.join(childDir, 'governance-b');
95
+ await fs.mkdir(gov1, { recursive: true });
96
+ await fs.mkdir(gov2, { recursive: true });
97
+ await fs.writeFile(path.join(gov1, '.projitive'), '', 'utf-8');
98
+ await fs.writeFile(path.join(gov2, '.projitive'), '', 'utf-8');
99
+ await expect(resolveGovernanceDir(childDir)).rejects.toThrow('Multiple governance roots found');
100
+ });
85
101
  it('prefers default .projitive directory when multiple governance roots found as children', async () => {
86
102
  const root = await createTempDir();
87
103
  const childDir = path.join(root, 'child');
@@ -246,10 +262,8 @@ describe('projitive module', () => {
246
262
  await fs.writeFile(filePath, 'content', 'utf-8');
247
263
  await expect(initializeProjectStructure(filePath)).rejects.toThrow('projectPath must be a directory');
248
264
  });
249
- it('creates governance structure with default name when invalid names are provided', async () => {
265
+ it('uses default governance dir when governanceDir is omitted', async () => {
250
266
  const root = await createTempDir();
251
- // When governanceDir is invalid, it should fall back to default
252
- // Note: normalizeGovernanceDirName is not exported, so we test initialization behavior
253
267
  const initialized = await initializeProjectStructure(root);
254
268
  expect(initialized.governanceDir).toBe(path.join(root, '.projitive'));
255
269
  });
@@ -271,6 +285,19 @@ describe('projitive module', () => {
271
285
  expect(initialized.directories.some(d => d.path.includes('reports'))).toBe(true);
272
286
  expect(initialized.directories.some(d => d.path.includes('templates'))).toBe(true);
273
287
  });
288
+ it('throws when governanceDir is an absolute path', async () => {
289
+ const root = await createTempDir();
290
+ await expect(initializeProjectStructure(root, '/absolute/path')).rejects.toThrow('relative directory name');
291
+ });
292
+ it('throws when governanceDir contains path separators', async () => {
293
+ const root = await createTempDir();
294
+ await expect(initializeProjectStructure(root, 'path/with/slash')).rejects.toThrow('path separators');
295
+ });
296
+ it('throws when governanceDir is a dot or double-dot', async () => {
297
+ const root = await createTempDir();
298
+ await expect(initializeProjectStructure(root, '.')).rejects.toThrow('normal directory name');
299
+ await expect(initializeProjectStructure(root, '..')).rejects.toThrow('normal directory name');
300
+ });
274
301
  });
275
302
  describe('utility functions', () => {
276
303
  describe('toProjectPath', () => {
@@ -330,6 +357,22 @@ describe('projitive module', () => {
330
357
  expect(() => resolveScanDepth()).toThrow('Invalid PROJITIVE_SCAN_MAX_DEPTH');
331
358
  vi.unstubAllEnvs();
332
359
  });
360
+ it('throws when PROJITIVE_SCAN_MAX_DEPTH env var is missing', () => {
361
+ vi.unstubAllEnvs();
362
+ expect(() => resolveScanDepth()).toThrow('Missing required environment variable: PROJITIVE_SCAN_MAX_DEPTH');
363
+ });
364
+ });
365
+ describe('resolveScanRoot', () => {
366
+ it('returns first scan root from env', () => {
367
+ vi.stubEnv('PROJITIVE_SCAN_ROOT_PATH', '/test/root');
368
+ expect(resolveScanRoot()).toBe('/test/root');
369
+ vi.unstubAllEnvs();
370
+ });
371
+ it('returns normalized path when inputPath is provided', () => {
372
+ vi.stubEnv('PROJITIVE_SCAN_ROOT_PATH', '/fallback');
373
+ expect(resolveScanRoot('/custom/path')).toBe('/custom/path');
374
+ vi.unstubAllEnvs();
375
+ });
333
376
  });
334
377
  });
335
378
  describe('registerProjectTools', () => {
@@ -363,5 +406,94 @@ describe('projitive module', () => {
363
406
  expect(markdown).toContain(`1. ${projectRoot}`);
364
407
  expect(markdown).not.toContain(`1. ${governanceDir}`);
365
408
  });
409
+ it('projectScan returns no-project guidance when scan root is empty', async () => {
410
+ const emptyRoot = await createTempDir();
411
+ vi.stubEnv('PROJITIVE_SCAN_ROOT_PATHS', emptyRoot);
412
+ vi.stubEnv('PROJITIVE_SCAN_MAX_DEPTH', '2');
413
+ const mockServer = { registerTool: vi.fn() };
414
+ registerProjectTools(mockServer);
415
+ const projectScan = getProjectToolHandler(mockServer, 'projectScan');
416
+ const result = await projectScan();
417
+ expect(result.isError).toBeUndefined();
418
+ expect(result.content[0].text).toContain('No governance root discovered');
419
+ });
420
+ it('projectNext ranks multiple actionable projects by score', async () => {
421
+ const scanRoot = await createTempDir();
422
+ const projectA = path.join(scanRoot, 'app-a');
423
+ const projectB = path.join(scanRoot, 'app-b');
424
+ await fs.mkdir(projectA, { recursive: true });
425
+ await fs.mkdir(projectB, { recursive: true });
426
+ await initializeProjectStructure(projectA);
427
+ await initializeProjectStructure(projectB);
428
+ vi.stubEnv('PROJITIVE_SCAN_ROOT_PATHS', scanRoot);
429
+ vi.stubEnv('PROJITIVE_SCAN_MAX_DEPTH', '3');
430
+ const mockServer = { registerTool: vi.fn() };
431
+ registerProjectTools(mockServer);
432
+ const projectNext = getProjectToolHandler(mockServer, 'projectNext');
433
+ const result = await projectNext({});
434
+ expect(result.isError).toBeUndefined();
435
+ expect(result.content[0].text).toContain('actionableProjects:');
436
+ });
437
+ it('projectInit handler initializes project structure', async () => {
438
+ const root = await createTempDir();
439
+ const mockServer = { registerTool: vi.fn() };
440
+ registerProjectTools(mockServer);
441
+ const projectInit = getProjectToolHandler(mockServer, 'projectInit');
442
+ const result = await projectInit({ projectPath: root });
443
+ expect(result.isError).toBeUndefined();
444
+ expect(result.content[0].text).toContain('governanceDir:');
445
+ expect(result.content[0].text).toContain('createdFiles:');
446
+ });
447
+ it('projectLocate resolves governance dir from any inner path', async () => {
448
+ const root = await createTempDir();
449
+ const projectRoot = path.join(root, 'myapp');
450
+ const governanceDir = path.join(projectRoot, '.projitive');
451
+ await fs.mkdir(governanceDir, { recursive: true });
452
+ await fs.writeFile(path.join(governanceDir, '.projitive'), '', 'utf-8');
453
+ const mockServer = { registerTool: vi.fn() };
454
+ registerProjectTools(mockServer);
455
+ const projectLocate = getProjectToolHandler(mockServer, 'projectLocate');
456
+ const result = await projectLocate({ inputPath: governanceDir });
457
+ expect(result.isError).toBeUndefined();
458
+ expect(result.content[0].text).toContain(`projectPath: ${projectRoot}`);
459
+ expect(result.content[0].text).toContain(`governanceDir: ${governanceDir}`);
460
+ });
461
+ it('projectNext ranks actionable projects by score', async () => {
462
+ const scanRoot = await createTempDir();
463
+ const projectRoot = path.join(scanRoot, 'myapp');
464
+ await fs.mkdir(projectRoot, { recursive: true });
465
+ await initializeProjectStructure(projectRoot);
466
+ vi.stubEnv('PROJITIVE_SCAN_ROOT_PATHS', scanRoot);
467
+ vi.stubEnv('PROJITIVE_SCAN_MAX_DEPTH', '3');
468
+ const mockServer = { registerTool: vi.fn() };
469
+ registerProjectTools(mockServer);
470
+ const projectNext = getProjectToolHandler(mockServer, 'projectNext');
471
+ const result = await projectNext({});
472
+ expect(result.isError).toBeUndefined();
473
+ expect(result.content[0].text).toContain('actionableProjects:');
474
+ expect(result.content[0].text).toContain('myapp');
475
+ });
476
+ it('projectContext shows task stats and governance artifacts', async () => {
477
+ const root = await createTempDir();
478
+ await initializeProjectStructure(root);
479
+ const mockServer = { registerTool: vi.fn() };
480
+ registerProjectTools(mockServer);
481
+ const projectContext = getProjectToolHandler(mockServer, 'projectContext');
482
+ const result = await projectContext({ projectPath: root });
483
+ expect(result.isError).toBeUndefined();
484
+ expect(result.content[0].text).toContain('Task Summary');
485
+ expect(result.content[0].text).toContain('Artifacts');
486
+ });
487
+ it('syncViews materializes both tasks and roadmap markdown views', async () => {
488
+ const root = await createTempDir();
489
+ await initializeProjectStructure(root);
490
+ const mockServer = { registerTool: vi.fn() };
491
+ registerProjectTools(mockServer);
492
+ const syncViews = getProjectToolHandler(mockServer, 'syncViews');
493
+ const result = await syncViews({ projectPath: root, views: ['tasks', 'roadmap'], force: true });
494
+ expect(result.isError).toBeUndefined();
495
+ expect(result.content[0].text).toContain('tasks.md synced');
496
+ expect(result.content[0].text).toContain('roadmap.md synced');
497
+ });
366
498
  });
367
499
  });