@projitive/mcp 1.0.7 → 1.1.1

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 (56) hide show
  1. package/README.md +3 -3
  2. package/output/package.json +4 -1
  3. package/output/source/{helpers/catch → common}/catch.js +6 -6
  4. package/output/source/{helpers/catch → common}/catch.test.js +6 -6
  5. package/output/source/common/confidence.js +231 -0
  6. package/output/source/common/confidence.test.js +205 -0
  7. package/output/source/common/errors.js +120 -0
  8. package/output/source/{helpers/files → common}/files.js +1 -1
  9. package/output/source/{helpers/files → common}/files.test.js +1 -1
  10. package/output/source/common/index.js +10 -0
  11. package/output/source/{helpers/linter/codes.js → common/linter.js} +13 -0
  12. package/output/source/{helpers/markdown → common}/markdown.test.js +1 -1
  13. package/output/source/{helpers/response → common}/response.test.js +1 -1
  14. package/output/source/common/types.js +7 -0
  15. package/output/source/common/utils.js +39 -0
  16. package/output/source/design-context.js +51 -500
  17. package/output/source/index.js +8 -193
  18. package/output/source/index.test.js +116 -0
  19. package/output/source/prompts/index.js +9 -0
  20. package/output/source/prompts/quickStart.js +94 -0
  21. package/output/source/prompts/taskDiscovery.js +190 -0
  22. package/output/source/prompts/taskExecution.js +161 -0
  23. package/output/source/resources/designs.js +108 -0
  24. package/output/source/resources/designs.test.js +154 -0
  25. package/output/source/resources/governance.js +40 -0
  26. package/output/source/resources/index.js +6 -0
  27. package/output/source/resources/readme.test.js +167 -0
  28. package/output/source/{reports.js → resources/reports.js} +5 -3
  29. package/output/source/resources/reports.test.js +149 -0
  30. package/output/source/tools/index.js +8 -0
  31. package/output/source/{projitive.js → tools/project.js} +6 -9
  32. package/output/source/tools/project.test.js +322 -0
  33. package/output/source/{roadmap.js → tools/roadmap.js} +4 -7
  34. package/output/source/tools/roadmap.test.js +103 -0
  35. package/output/source/{tasks.js → tools/task.js} +581 -27
  36. package/output/source/tools/task.test.js +473 -0
  37. package/output/source/types.js +67 -0
  38. package/package.json +4 -1
  39. package/output/source/designs.js +0 -38
  40. package/output/source/helpers/artifacts/index.js +0 -1
  41. package/output/source/helpers/catch/index.js +0 -1
  42. package/output/source/helpers/files/index.js +0 -1
  43. package/output/source/helpers/index.js +0 -6
  44. package/output/source/helpers/linter/index.js +0 -2
  45. package/output/source/helpers/linter/linter.js +0 -6
  46. package/output/source/helpers/markdown/index.js +0 -1
  47. package/output/source/helpers/response/index.js +0 -1
  48. package/output/source/projitive.test.js +0 -111
  49. package/output/source/roadmap.test.js +0 -11
  50. package/output/source/tasks.test.js +0 -152
  51. /package/output/source/{helpers/artifacts → common}/artifacts.js +0 -0
  52. /package/output/source/{helpers/artifacts → common}/artifacts.test.js +0 -0
  53. /package/output/source/{helpers/linter → common}/linter.test.js +0 -0
  54. /package/output/source/{helpers/markdown → common}/markdown.js +0 -0
  55. /package/output/source/{helpers/response → common}/response.js +0 -0
  56. /package/output/source/{readme.js → resources/readme.js} +0 -0
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseReportMetadata, validateReportMetadata, } from "./reports.js";
3
+ describe("reports module", () => {
4
+ describe("parseReportMetadata", () => {
5
+ it("parses task metadata from markdown", () => {
6
+ const markdown = [
7
+ "# Task Report",
8
+ "",
9
+ "**Task:** TASK-0001",
10
+ "**Owner:** ai-copilot",
11
+ "**Date:** 2026-02-22",
12
+ "",
13
+ "Some content here",
14
+ ].join("\n");
15
+ const metadata = parseReportMetadata(markdown);
16
+ expect(metadata.task).toBe("TASK-0001");
17
+ expect(metadata.owner).toBe("ai-copilot");
18
+ expect(metadata.date).toBe("2026-02-22");
19
+ });
20
+ it("parses roadmap metadata from markdown", () => {
21
+ const markdown = [
22
+ "# Roadmap Report",
23
+ "",
24
+ "**Roadmap:** ROADMAP-0001",
25
+ "",
26
+ "Some content here",
27
+ ].join("\n");
28
+ const metadata = parseReportMetadata(markdown);
29
+ expect(metadata.roadmap).toBe("ROADMAP-0001");
30
+ });
31
+ it("returns empty object for markdown without metadata", () => {
32
+ const markdown = [
33
+ "# Simple Report",
34
+ "",
35
+ "No metadata here",
36
+ ].join("\n");
37
+ const metadata = parseReportMetadata(markdown);
38
+ expect(metadata).toEqual({});
39
+ });
40
+ it("handles empty string", () => {
41
+ const metadata = parseReportMetadata("");
42
+ expect(metadata).toEqual({});
43
+ });
44
+ it("handles malformed metadata lines", () => {
45
+ const markdown = [
46
+ "# Report",
47
+ "",
48
+ "Task without colon",
49
+ "Not a metadata line",
50
+ ":",
51
+ "Task:",
52
+ ].join("\n");
53
+ const metadata = parseReportMetadata(markdown);
54
+ expect(metadata).toBeDefined();
55
+ });
56
+ it("parses metadata in different formats", () => {
57
+ const markdown = [
58
+ "Task: TASK-0001",
59
+ "task: TASK-0002",
60
+ "TASK: TASK-0003",
61
+ " task : TASK-0004 ",
62
+ ].join("\n");
63
+ const metadata = parseReportMetadata(markdown);
64
+ expect(metadata.task).toBeDefined();
65
+ });
66
+ });
67
+ describe("validateReportMetadata", () => {
68
+ it("validates correct task metadata", () => {
69
+ const metadata = {
70
+ task: "TASK-0001",
71
+ owner: "ai-copilot",
72
+ date: "2026-02-22",
73
+ };
74
+ const result = validateReportMetadata(metadata);
75
+ expect(result.ok).toBe(true);
76
+ expect(result.errors).toEqual([]);
77
+ });
78
+ it("rejects missing task metadata", () => {
79
+ const metadata = {
80
+ owner: "ai-copilot",
81
+ };
82
+ const result = validateReportMetadata(metadata);
83
+ expect(result.ok).toBe(false);
84
+ expect(result.errors).toContain("Missing Task metadata");
85
+ });
86
+ it("rejects invalid task ID format", () => {
87
+ const metadata = {
88
+ task: "invalid-format",
89
+ };
90
+ const result = validateReportMetadata(metadata);
91
+ expect(result.ok).toBe(false);
92
+ expect(result.errors.some((e) => e.includes("Invalid Task"))).toBe(true);
93
+ });
94
+ it("validates optional roadmap metadata", () => {
95
+ const metadata = {
96
+ task: "TASK-0001",
97
+ roadmap: "ROADMAP-0001",
98
+ };
99
+ const result = validateReportMetadata(metadata);
100
+ expect(result.ok).toBe(true);
101
+ });
102
+ it("rejects invalid roadmap ID format", () => {
103
+ const metadata = {
104
+ task: "TASK-0001",
105
+ roadmap: "invalid-roadmap",
106
+ };
107
+ const result = validateReportMetadata(metadata);
108
+ expect(result.ok).toBe(false);
109
+ expect(result.errors.some((e) => e.includes("Invalid Roadmap"))).toBe(true);
110
+ });
111
+ it("handles empty metadata object", () => {
112
+ const metadata = {};
113
+ const result = validateReportMetadata(metadata);
114
+ expect(result.ok).toBe(false);
115
+ expect(result.errors.length).toBeGreaterThan(0);
116
+ });
117
+ it("collects multiple validation errors", () => {
118
+ const metadata = {
119
+ task: "invalid-task",
120
+ roadmap: "invalid-roadmap",
121
+ };
122
+ const result = validateReportMetadata(metadata);
123
+ expect(result.ok).toBe(false);
124
+ expect(result.errors.length).toBeGreaterThan(1);
125
+ });
126
+ });
127
+ describe("integration", () => {
128
+ it("parses and validates complete report metadata", () => {
129
+ const markdown = [
130
+ "# Task Completion Report",
131
+ "",
132
+ "**Task:** TASK-0001",
133
+ "**Roadmap:** ROADMAP-0002",
134
+ "**Owner:** ai-copilot",
135
+ "**Date:** 2026-02-22",
136
+ "",
137
+ "## Summary",
138
+ "Task completed successfully",
139
+ ].join("\n");
140
+ const metadata = parseReportMetadata(markdown);
141
+ const validation = validateReportMetadata(metadata);
142
+ expect(metadata.task).toBe("TASK-0001");
143
+ expect(metadata.roadmap).toBe("ROADMAP-0002");
144
+ expect(metadata.owner).toBe("ai-copilot");
145
+ expect(metadata.date).toBe("2026-02-22");
146
+ expect(validation.ok).toBe(true);
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,8 @@
1
+ import { registerProjectTools } from "./project.js";
2
+ import { registerTaskTools } from "./task.js";
3
+ import { registerRoadmapTools } from "./roadmap.js";
4
+ export function registerTools(server) {
5
+ registerProjectTools(server);
6
+ registerTaskTools(server);
7
+ registerRoadmapTools(server);
8
+ }
@@ -2,11 +2,9 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { z } from "zod";
5
- import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
- import { catchIt } from "./helpers/catch/index.js";
7
- import { PROJECT_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
8
- import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
9
- import { collectTaskLintSuggestions, loadTasksDocument } from "./tasks.js";
5
+ import { discoverGovernanceArtifacts, catchIt, PROJECT_LINT_CODES, renderLintSuggestions } from "../common/index.js";
6
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
7
+ import { collectTaskLintSuggestions, loadTasksDocument } from "./task.js";
10
8
  export const PROJECT_MARKER = ".projitive";
11
9
  const DEFAULT_GOVERNANCE_DIR = ".projitive";
12
10
  const ignoreNames = new Set(["node_modules", ".git", ".next", "dist", "build"]);
@@ -93,7 +91,7 @@ async function readTasksSnapshot(governanceDir) {
93
91
  ]),
94
92
  };
95
93
  }
96
- const { parseTasksBlock } = await import("./tasks.js");
94
+ const { parseTasksBlock } = await import("./task.js");
97
95
  const tasks = await parseTasksBlock(markdown);
98
96
  return { tasksPath, exists: true, tasks, lintSuggestions: collectTaskLintSuggestions(tasks, markdown) };
99
97
  }
@@ -397,12 +395,11 @@ export function registerProjectTools(server) {
397
395
  title: "Project Next",
398
396
  description: "Rank actionable projects and return the best execution target",
399
397
  inputSchema: {
400
- maxDepth: z.number().int().min(0).max(8).optional(),
401
398
  limit: z.number().int().min(1).max(50).optional(),
402
399
  },
403
- }, async ({ maxDepth, limit }) => {
400
+ }, async ({ limit }) => {
404
401
  const root = resolveScanRoot();
405
- const depth = resolveScanDepth(maxDepth);
402
+ const depth = resolveScanDepth();
406
403
  const projects = await discoverProjects(root, depth);
407
404
  const snapshots = await Promise.all(projects.map(async (governanceDir) => {
408
405
  const snapshot = await readTasksSnapshot(governanceDir);
@@ -0,0 +1,322 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { discoverProjects, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoot, resolveScanDepth, toProjectPath, registerProjectTools } from "./project.js";
6
+ const tempPaths = [];
7
+ async function createTempDir() {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
9
+ tempPaths.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(async () => {
13
+ await Promise.all(tempPaths.splice(0).map(async (dir) => {
14
+ await fs.rm(dir, { recursive: true, force: true });
15
+ }));
16
+ vi.restoreAllMocks();
17
+ });
18
+ describe("projitive module", () => {
19
+ describe("hasProjectMarker", () => {
20
+ it("does not treat marker directory as a valid project marker", async () => {
21
+ const root = await createTempDir();
22
+ const dirMarkerPath = path.join(root, ".projitive");
23
+ await fs.mkdir(dirMarkerPath, { recursive: true });
24
+ const hasMarker = await hasProjectMarker(root);
25
+ expect(hasMarker).toBe(false);
26
+ });
27
+ it("returns true when .projitive marker file exists", async () => {
28
+ const root = await createTempDir();
29
+ const markerPath = path.join(root, ".projitive");
30
+ await fs.writeFile(markerPath, "", "utf-8");
31
+ const hasMarker = await hasProjectMarker(root);
32
+ expect(hasMarker).toBe(true);
33
+ });
34
+ it("returns false when .projitive marker file does not exist", async () => {
35
+ const root = await createTempDir();
36
+ const hasMarker = await hasProjectMarker(root);
37
+ expect(hasMarker).toBe(false);
38
+ });
39
+ it("handles fs.stat errors gracefully", async () => {
40
+ const root = await createTempDir();
41
+ vi.spyOn(fs, "stat").mockRejectedValueOnce(new Error("Permission denied"));
42
+ const hasMarker = await hasProjectMarker(root);
43
+ expect(hasMarker).toBe(false);
44
+ });
45
+ });
46
+ describe("resolveGovernanceDir", () => {
47
+ it("resolves governance dir by walking upwards for .projitive", async () => {
48
+ const root = await createTempDir();
49
+ const governanceDir = path.join(root, "repo", "governance");
50
+ const deepDir = path.join(governanceDir, "nested", "module");
51
+ await fs.mkdir(deepDir, { recursive: true });
52
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
53
+ const resolved = await resolveGovernanceDir(deepDir);
54
+ expect(resolved).toBe(governanceDir);
55
+ });
56
+ it("resolves nested default governance dir when input path is project root", async () => {
57
+ const root = await createTempDir();
58
+ const projectRoot = path.join(root, "repo");
59
+ const governanceDir = path.join(projectRoot, ".projitive");
60
+ await fs.mkdir(governanceDir, { recursive: true });
61
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
62
+ const resolved = await resolveGovernanceDir(projectRoot);
63
+ expect(resolved).toBe(governanceDir);
64
+ });
65
+ it("resolves nested custom governance dir when input path is project root", async () => {
66
+ const root = await createTempDir();
67
+ const projectRoot = path.join(root, "repo");
68
+ const governanceDir = path.join(projectRoot, "governance");
69
+ await fs.mkdir(governanceDir, { recursive: true });
70
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
71
+ const resolved = await resolveGovernanceDir(projectRoot);
72
+ expect(resolved).toBe(governanceDir);
73
+ });
74
+ it("throws error when path not found", async () => {
75
+ const root = await createTempDir();
76
+ const nonExistentPath = path.join(root, "nonexistent");
77
+ await expect(resolveGovernanceDir(nonExistentPath)).rejects.toThrow("Path not found");
78
+ });
79
+ it("throws error when no .projitive marker found", async () => {
80
+ const root = await createTempDir();
81
+ const deepDir = path.join(root, "a", "b", "c");
82
+ await fs.mkdir(deepDir, { recursive: true });
83
+ await expect(resolveGovernanceDir(deepDir)).rejects.toThrow("No .projitive marker found");
84
+ });
85
+ it("prefers default .projitive directory when multiple governance roots found as children", async () => {
86
+ const root = await createTempDir();
87
+ const childDir = path.join(root, "child");
88
+ const governance1 = path.join(childDir, ".projitive");
89
+ const governance2 = path.join(childDir, "governance");
90
+ await fs.mkdir(governance1, { recursive: true });
91
+ await fs.mkdir(governance2, { recursive: true });
92
+ await fs.writeFile(path.join(governance1, ".projitive"), "", "utf-8");
93
+ await fs.writeFile(path.join(governance2, ".projitive"), "", "utf-8");
94
+ const resolved = await resolveGovernanceDir(childDir);
95
+ expect(resolved).toBe(governance1); // Should prefer default .projitive
96
+ });
97
+ it("resolves file path by using its directory", async () => {
98
+ const root = await createTempDir();
99
+ const governanceDir = path.join(root, ".projitive");
100
+ const filePath = path.join(governanceDir, "tasks.md");
101
+ await fs.mkdir(governanceDir, { recursive: true });
102
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
103
+ await fs.writeFile(filePath, "# Tasks", "utf-8");
104
+ const resolved = await resolveGovernanceDir(filePath);
105
+ expect(resolved).toBe(governanceDir);
106
+ });
107
+ });
108
+ describe("discoverProjects", () => {
109
+ it("discovers projects by marker file", async () => {
110
+ const root = await createTempDir();
111
+ const p1 = path.join(root, "a");
112
+ const p2 = path.join(root, "b", "c");
113
+ await fs.mkdir(p1, { recursive: true });
114
+ await fs.mkdir(p2, { recursive: true });
115
+ await fs.writeFile(path.join(p1, ".projitive"), "", "utf-8");
116
+ await fs.writeFile(path.join(p2, ".projitive"), "", "utf-8");
117
+ const projects = await discoverProjects(root, 4);
118
+ expect(projects).toContain(p1);
119
+ expect(projects).toContain(p2);
120
+ });
121
+ it("discovers nested default governance directory under project root", async () => {
122
+ const root = await createTempDir();
123
+ const projectRoot = path.join(root, "app");
124
+ const governanceDir = path.join(projectRoot, ".projitive");
125
+ await fs.mkdir(governanceDir, { recursive: true });
126
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
127
+ const projects = await discoverProjects(root, 3);
128
+ expect(projects).toContain(governanceDir);
129
+ });
130
+ it("discovers nested custom governance directory under project root", async () => {
131
+ const root = await createTempDir();
132
+ const projectRoot = path.join(root, "app");
133
+ const governanceDir = path.join(projectRoot, "governance");
134
+ await fs.mkdir(governanceDir, { recursive: true });
135
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
136
+ const projects = await discoverProjects(root, 3);
137
+ expect(projects).toContain(governanceDir);
138
+ });
139
+ it("respects maxDepth limit", async () => {
140
+ const root = await createTempDir();
141
+ const shallow = path.join(root, "shallow");
142
+ const deep = path.join(root, "level1", "level2", "level3", "level4", "deep");
143
+ await fs.mkdir(shallow, { recursive: true });
144
+ await fs.mkdir(deep, { recursive: true });
145
+ await fs.writeFile(path.join(shallow, ".projitive"), "", "utf-8");
146
+ await fs.writeFile(path.join(deep, ".projitive"), "", "utf-8");
147
+ const projects = await discoverProjects(root, 3);
148
+ expect(projects).toContain(shallow);
149
+ expect(projects).not.toContain(deep);
150
+ });
151
+ it("ignores common ignore directories", async () => {
152
+ const root = await createTempDir();
153
+ const nodeModulesProject = path.join(root, "node_modules", "project");
154
+ const gitProject = path.join(root, ".git", "project");
155
+ const validProject = path.join(root, "valid");
156
+ await fs.mkdir(nodeModulesProject, { recursive: true });
157
+ await fs.mkdir(gitProject, { recursive: true });
158
+ await fs.mkdir(validProject, { recursive: true });
159
+ await fs.writeFile(path.join(nodeModulesProject, ".projitive"), "", "utf-8");
160
+ await fs.writeFile(path.join(gitProject, ".projitive"), "", "utf-8");
161
+ await fs.writeFile(path.join(validProject, ".projitive"), "", "utf-8");
162
+ const projects = await discoverProjects(root, 3);
163
+ expect(projects).toContain(validProject);
164
+ expect(projects).not.toContain(nodeModulesProject);
165
+ expect(projects).not.toContain(gitProject);
166
+ });
167
+ it("returns empty array when no projects found", async () => {
168
+ const root = await createTempDir();
169
+ const projects = await discoverProjects(root, 3);
170
+ expect(projects).toEqual([]);
171
+ });
172
+ it("returns unique and sorted results", async () => {
173
+ const root = await createTempDir();
174
+ const projectB = path.join(root, "b");
175
+ const projectA = path.join(root, "a");
176
+ await fs.mkdir(projectB, { recursive: true });
177
+ await fs.mkdir(projectA, { recursive: true });
178
+ await fs.writeFile(path.join(projectB, ".projitive"), "", "utf-8");
179
+ await fs.writeFile(path.join(projectA, ".projitive"), "", "utf-8");
180
+ const projects = await discoverProjects(root, 3);
181
+ expect(projects).toEqual([projectA, projectB]);
182
+ });
183
+ it("handles fs.readdir errors gracefully", async () => {
184
+ const root = await createTempDir();
185
+ vi.spyOn(fs, "readdir").mockRejectedValueOnce(new Error("Permission denied"));
186
+ const projects = await discoverProjects(root, 3);
187
+ expect(projects).toEqual([]);
188
+ });
189
+ });
190
+ describe("initializeProjectStructure", () => {
191
+ it("initializes governance structure under default .projitive directory", async () => {
192
+ const root = await createTempDir();
193
+ const initialized = await initializeProjectStructure(root);
194
+ expect(initialized.governanceDir).toBe(path.join(root, ".projitive"));
195
+ const expectedPaths = [
196
+ path.join(root, ".projitive", ".projitive"),
197
+ path.join(root, ".projitive", "README.md"),
198
+ path.join(root, ".projitive", "roadmap.md"),
199
+ path.join(root, ".projitive", "tasks.md"),
200
+ path.join(root, ".projitive", "hooks", "task_no_actionable.md"),
201
+ path.join(root, ".projitive", "designs"),
202
+ path.join(root, ".projitive", "reports"),
203
+ path.join(root, ".projitive", "hooks"),
204
+ ];
205
+ await Promise.all(expectedPaths.map(async (targetPath) => {
206
+ await expect(fs.access(targetPath)).resolves.toBeUndefined();
207
+ }));
208
+ });
209
+ it("overwrites template files when force is enabled", async () => {
210
+ const root = await createTempDir();
211
+ const governanceDir = path.join(root, ".projitive");
212
+ const readmePath = path.join(governanceDir, "README.md");
213
+ await initializeProjectStructure(root);
214
+ await fs.writeFile(readmePath, "custom-content", "utf-8");
215
+ const initialized = await initializeProjectStructure(root, ".projitive", true);
216
+ const readmeContent = await fs.readFile(readmePath, "utf-8");
217
+ expect(readmeContent).toContain("Projitive Governance Workspace");
218
+ expect(initialized.files.find((item) => item.path === readmePath)?.action).toBe("updated");
219
+ });
220
+ it("uses custom governance directory when specified", async () => {
221
+ const root = await createTempDir();
222
+ const customDir = "my-governance";
223
+ const initialized = await initializeProjectStructure(root, customDir);
224
+ expect(initialized.governanceDir).toBe(path.join(root, customDir));
225
+ });
226
+ it("throws error when project path not found", async () => {
227
+ const root = await createTempDir();
228
+ const nonExistentPath = path.join(root, "nonexistent");
229
+ await expect(initializeProjectStructure(nonExistentPath)).rejects.toThrow("Path not found");
230
+ });
231
+ it("throws error when project path is not a directory", async () => {
232
+ const root = await createTempDir();
233
+ const filePath = path.join(root, "file.txt");
234
+ await fs.writeFile(filePath, "content", "utf-8");
235
+ await expect(initializeProjectStructure(filePath)).rejects.toThrow("projectPath must be a directory");
236
+ });
237
+ it("creates governance structure with default name when invalid names are provided", async () => {
238
+ const root = await createTempDir();
239
+ // When governanceDir is invalid, it should fall back to default
240
+ // Note: normalizeGovernanceDirName is not exported, so we test initialization behavior
241
+ const initialized = await initializeProjectStructure(root);
242
+ expect(initialized.governanceDir).toBe(path.join(root, ".projitive"));
243
+ });
244
+ it("skips existing files when force is disabled", async () => {
245
+ const root = await createTempDir();
246
+ const governanceDir = path.join(root, ".projitive");
247
+ const readmePath = path.join(governanceDir, "README.md");
248
+ await initializeProjectStructure(root);
249
+ await fs.writeFile(readmePath, "custom-content", "utf-8");
250
+ const initialized = await initializeProjectStructure(root, ".projitive", false);
251
+ const readmeContent = await fs.readFile(readmePath, "utf-8");
252
+ expect(readmeContent).toBe("custom-content");
253
+ expect(initialized.files.find((item) => item.path === readmePath)?.action).toBe("skipped");
254
+ });
255
+ it("creates all required subdirectories", async () => {
256
+ const root = await createTempDir();
257
+ const initialized = await initializeProjectStructure(root);
258
+ expect(initialized.directories.some(d => d.path.includes("designs"))).toBe(true);
259
+ expect(initialized.directories.some(d => d.path.includes("reports"))).toBe(true);
260
+ expect(initialized.directories.some(d => d.path.includes("hooks"))).toBe(true);
261
+ });
262
+ });
263
+ describe("utility functions", () => {
264
+ describe("toProjectPath", () => {
265
+ it("returns parent directory of governance dir", () => {
266
+ expect(toProjectPath("/path/to/project/.projitive")).toBe("/path/to/project");
267
+ expect(toProjectPath("/a/b/c")).toBe("/a/b");
268
+ });
269
+ });
270
+ describe("resolveScanRoot", () => {
271
+ it("uses environment variable when no input path", () => {
272
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
273
+ expect(resolveScanRoot()).toBe("/test/root");
274
+ vi.unstubAllEnvs();
275
+ });
276
+ it("uses input path when provided", () => {
277
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
278
+ expect(resolveScanRoot("/custom/path")).toBe("/custom/path");
279
+ vi.unstubAllEnvs();
280
+ });
281
+ it("throws error when required environment variable missing", () => {
282
+ vi.unstubAllEnvs();
283
+ expect(() => resolveScanRoot()).toThrow("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATH");
284
+ });
285
+ });
286
+ describe("resolveScanDepth", () => {
287
+ it("uses environment variable when no input depth", () => {
288
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
289
+ vi.stubEnv("PROJITIVE_SCAN_MAX_DEPTH", "5");
290
+ expect(resolveScanDepth()).toBe(5);
291
+ vi.unstubAllEnvs();
292
+ });
293
+ it("uses input depth when provided", () => {
294
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
295
+ vi.stubEnv("PROJITIVE_SCAN_MAX_DEPTH", "5");
296
+ expect(resolveScanDepth(3)).toBe(3);
297
+ vi.unstubAllEnvs();
298
+ });
299
+ it("clamps depth to MAX_SCAN_DEPTH", () => {
300
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
301
+ vi.stubEnv("PROJITIVE_SCAN_MAX_DEPTH", "10");
302
+ expect(resolveScanDepth()).toBe(8);
303
+ vi.unstubAllEnvs();
304
+ });
305
+ it("throws error for invalid depth configuration", () => {
306
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
307
+ vi.stubEnv("PROJITIVE_SCAN_MAX_DEPTH", "not-a-number");
308
+ expect(() => resolveScanDepth()).toThrow("Invalid PROJITIVE_SCAN_MAX_DEPTH");
309
+ vi.unstubAllEnvs();
310
+ });
311
+ });
312
+ });
313
+ describe("registerProjectTools", () => {
314
+ it("registers project tools without throwing", () => {
315
+ const mockServer = {
316
+ registerTool: vi.fn(),
317
+ };
318
+ expect(() => registerProjectTools(mockServer)).not.toThrow();
319
+ expect(mockServer.registerTool).toHaveBeenCalled();
320
+ });
321
+ });
322
+ });
@@ -1,13 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { candidateFilesFromArtifacts } from "./helpers/artifacts/index.js";
5
- import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
- import { ROADMAP_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
7
- import { findTextReferences } from "./helpers/markdown/index.js";
8
- import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
9
- import { resolveGovernanceDir, toProjectPath } from "./projitive.js";
10
- import { loadTasks } from "./tasks.js";
4
+ import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, ROADMAP_LINT_CODES, renderLintSuggestions, findTextReferences } from "../common/index.js";
5
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
6
+ import { resolveGovernanceDir, toProjectPath } from "./project.js";
7
+ import { loadTasks } from "./task.js";
11
8
  export const ROADMAP_ID_REGEX = /^ROADMAP-\d{4}$/;
12
9
  function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
13
10
  const suggestions = [];
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { isValidRoadmapId, collectRoadmapLintSuggestions } from "./roadmap.js";
6
+ describe("roadmap module", () => {
7
+ let tempDir;
8
+ beforeAll(async () => {
9
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-roadmap-test-"));
10
+ });
11
+ afterAll(async () => {
12
+ await fs.rm(tempDir, { recursive: true, force: true });
13
+ });
14
+ describe("isValidRoadmapId", () => {
15
+ it("should validate correct roadmap IDs", () => {
16
+ expect(isValidRoadmapId("ROADMAP-0001")).toBe(true);
17
+ expect(isValidRoadmapId("ROADMAP-1234")).toBe(true);
18
+ expect(isValidRoadmapId("ROADMAP-9999")).toBe(true);
19
+ });
20
+ it("should reject invalid roadmap IDs", () => {
21
+ expect(isValidRoadmapId("roadmap-0001")).toBe(false);
22
+ expect(isValidRoadmapId("ROADMAP-001")).toBe(false);
23
+ expect(isValidRoadmapId("ROADMAP-00001")).toBe(false);
24
+ expect(isValidRoadmapId("TASK-0001")).toBe(false);
25
+ expect(isValidRoadmapId("")).toBe(false);
26
+ expect(isValidRoadmapId("invalid")).toBe(false);
27
+ });
28
+ });
29
+ describe("collectRoadmapLintSuggestions", () => {
30
+ it("should return lint suggestion for empty roadmap IDs", () => {
31
+ const suggestions = collectRoadmapLintSuggestions([], []);
32
+ expect(suggestions.some(s => s.includes("IDS_EMPTY"))).toBe(true);
33
+ });
34
+ it("should return lint suggestion for empty tasks", () => {
35
+ const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], []);
36
+ expect(suggestions.some(s => s.includes("TASKS_EMPTY"))).toBe(true);
37
+ });
38
+ it("should return lint suggestion for tasks without roadmap refs", () => {
39
+ const tasks = [
40
+ {
41
+ id: "TASK-0001",
42
+ title: "Test Task",
43
+ status: "TODO",
44
+ owner: "ai-copilot",
45
+ summary: "Test",
46
+ updatedAt: "2026-01-01T00:00:00.000Z",
47
+ links: [],
48
+ roadmapRefs: [],
49
+ },
50
+ ];
51
+ const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
52
+ expect(suggestions.some(s => s.includes("TASK_REFS_EMPTY"))).toBe(true);
53
+ });
54
+ it("should return lint suggestion for unknown roadmap refs", () => {
55
+ const tasks = [
56
+ {
57
+ id: "TASK-0001",
58
+ title: "Test Task",
59
+ status: "TODO",
60
+ owner: "ai-copilot",
61
+ summary: "Test",
62
+ updatedAt: "2026-01-01T00:00:00.000Z",
63
+ links: [],
64
+ roadmapRefs: ["ROADMAP-9999"],
65
+ },
66
+ ];
67
+ const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
68
+ expect(suggestions.some(s => s.includes("UNKNOWN_REFS"))).toBe(true);
69
+ });
70
+ it("should return lint suggestion for roadmaps with no linked tasks", () => {
71
+ const tasks = [
72
+ {
73
+ id: "TASK-0001",
74
+ title: "Test Task",
75
+ status: "TODO",
76
+ owner: "ai-copilot",
77
+ summary: "Test",
78
+ updatedAt: "2026-01-01T00:00:00.000Z",
79
+ links: [],
80
+ roadmapRefs: ["ROADMAP-0001"],
81
+ },
82
+ ];
83
+ const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001", "ROADMAP-0002"], tasks);
84
+ expect(suggestions.some(s => s.includes("ZERO_LINKED_TASKS"))).toBe(true);
85
+ });
86
+ it("should return no lint suggestions for valid setup", () => {
87
+ const tasks = [
88
+ {
89
+ id: "TASK-0001",
90
+ title: "Test Task",
91
+ status: "TODO",
92
+ owner: "ai-copilot",
93
+ summary: "Test",
94
+ updatedAt: "2026-01-01T00:00:00.000Z",
95
+ links: [],
96
+ roadmapRefs: ["ROADMAP-0001"],
97
+ },
98
+ ];
99
+ const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
100
+ expect(suggestions.length).toBe(0);
101
+ });
102
+ });
103
+ });