@task-mcp/shared 1.0.3 → 1.0.6

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 (88) hide show
  1. package/dist/algorithms/critical-path.d.ts.map +1 -1
  2. package/dist/algorithms/critical-path.js +50 -26
  3. package/dist/algorithms/critical-path.js.map +1 -1
  4. package/dist/algorithms/dependency-integrity.d.ts +73 -0
  5. package/dist/algorithms/dependency-integrity.d.ts.map +1 -0
  6. package/dist/algorithms/dependency-integrity.js +189 -0
  7. package/dist/algorithms/dependency-integrity.js.map +1 -0
  8. package/dist/algorithms/index.d.ts +2 -0
  9. package/dist/algorithms/index.d.ts.map +1 -1
  10. package/dist/algorithms/index.js +2 -0
  11. package/dist/algorithms/index.js.map +1 -1
  12. package/dist/algorithms/tech-analysis.d.ts +106 -0
  13. package/dist/algorithms/tech-analysis.d.ts.map +1 -0
  14. package/dist/algorithms/tech-analysis.js +296 -0
  15. package/dist/algorithms/tech-analysis.js.map +1 -0
  16. package/dist/algorithms/tech-analysis.test.d.ts +2 -0
  17. package/dist/algorithms/tech-analysis.test.d.ts.map +1 -0
  18. package/dist/algorithms/tech-analysis.test.js +338 -0
  19. package/dist/algorithms/tech-analysis.test.js.map +1 -0
  20. package/dist/algorithms/topological-sort.d.ts.map +1 -1
  21. package/dist/algorithms/topological-sort.js +60 -8
  22. package/dist/algorithms/topological-sort.js.map +1 -1
  23. package/dist/schemas/inbox.d.ts +24 -0
  24. package/dist/schemas/inbox.d.ts.map +1 -0
  25. package/dist/schemas/inbox.js +25 -0
  26. package/dist/schemas/inbox.js.map +1 -0
  27. package/dist/schemas/index.d.ts +3 -1
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas/index.js +9 -1
  30. package/dist/schemas/index.js.map +1 -1
  31. package/dist/schemas/response-format.d.ts +79 -0
  32. package/dist/schemas/response-format.d.ts.map +1 -0
  33. package/dist/schemas/response-format.js +17 -0
  34. package/dist/schemas/response-format.js.map +1 -0
  35. package/dist/schemas/task.d.ts +57 -0
  36. package/dist/schemas/task.d.ts.map +1 -1
  37. package/dist/schemas/task.js +34 -0
  38. package/dist/schemas/task.js.map +1 -1
  39. package/dist/utils/date.d.ts.map +1 -1
  40. package/dist/utils/date.js +17 -2
  41. package/dist/utils/date.js.map +1 -1
  42. package/dist/utils/hierarchy.d.ts +75 -0
  43. package/dist/utils/hierarchy.d.ts.map +1 -0
  44. package/dist/utils/hierarchy.js +179 -0
  45. package/dist/utils/hierarchy.js.map +1 -0
  46. package/dist/utils/id.d.ts +51 -1
  47. package/dist/utils/id.d.ts.map +1 -1
  48. package/dist/utils/id.js +124 -4
  49. package/dist/utils/id.js.map +1 -1
  50. package/dist/utils/id.test.d.ts +2 -0
  51. package/dist/utils/id.test.d.ts.map +1 -0
  52. package/dist/utils/id.test.js +228 -0
  53. package/dist/utils/id.test.js.map +1 -0
  54. package/dist/utils/index.d.ts +4 -2
  55. package/dist/utils/index.d.ts.map +1 -1
  56. package/dist/utils/index.js +7 -2
  57. package/dist/utils/index.js.map +1 -1
  58. package/dist/utils/natural-language.d.ts +45 -0
  59. package/dist/utils/natural-language.d.ts.map +1 -1
  60. package/dist/utils/natural-language.js +86 -0
  61. package/dist/utils/natural-language.js.map +1 -1
  62. package/dist/utils/projection.d.ts +65 -0
  63. package/dist/utils/projection.d.ts.map +1 -0
  64. package/dist/utils/projection.js +181 -0
  65. package/dist/utils/projection.js.map +1 -0
  66. package/dist/utils/projection.test.d.ts +2 -0
  67. package/dist/utils/projection.test.d.ts.map +1 -0
  68. package/dist/utils/projection.test.js +400 -0
  69. package/dist/utils/projection.test.js.map +1 -0
  70. package/package.json +1 -1
  71. package/src/algorithms/critical-path.ts +56 -24
  72. package/src/algorithms/dependency-integrity.ts +270 -0
  73. package/src/algorithms/index.ts +28 -0
  74. package/src/algorithms/tech-analysis.test.ts +413 -0
  75. package/src/algorithms/tech-analysis.ts +412 -0
  76. package/src/algorithms/topological-sort.ts +66 -9
  77. package/src/schemas/inbox.ts +32 -0
  78. package/src/schemas/index.ts +31 -0
  79. package/src/schemas/response-format.ts +108 -0
  80. package/src/schemas/task.ts +50 -0
  81. package/src/utils/date.ts +18 -2
  82. package/src/utils/hierarchy.ts +224 -0
  83. package/src/utils/id.test.ts +281 -0
  84. package/src/utils/id.ts +139 -4
  85. package/src/utils/index.ts +46 -2
  86. package/src/utils/natural-language.ts +113 -0
  87. package/src/utils/projection.test.ts +505 -0
  88. package/src/utils/projection.ts +251 -0
@@ -42,6 +42,43 @@ export const Recurrence = type({
42
42
  });
43
43
  export type Recurrence = typeof Recurrence.infer;
44
44
 
45
+ // Complexity factors that contribute to task difficulty
46
+ export const ComplexityFactor = type(
47
+ "'cross_cutting' | 'state_management' | 'error_handling' | 'performance' | 'security' | 'external_dependency' | 'data_migration' | 'breaking_change' | 'unclear_requirements' | 'coordination'"
48
+ );
49
+ export type ComplexityFactor = typeof ComplexityFactor.infer;
50
+
51
+ // Complexity analysis result (populated by Claude)
52
+ export const ComplexityAnalysis = type({
53
+ "score?": "number", // 1-10 complexity score
54
+ "factors?": ComplexityFactor.array(),
55
+ "suggestedSubtasks?": "number", // 0-10 recommended subtask count
56
+ "rationale?": "string",
57
+ "analyzedAt?": "string",
58
+ });
59
+ export type ComplexityAnalysis = typeof ComplexityAnalysis.infer;
60
+
61
+ // Tech area categories for ordering
62
+ export const TechArea = type(
63
+ "'schema' | 'backend' | 'frontend' | 'infra' | 'devops' | 'test' | 'docs' | 'refactor'"
64
+ );
65
+ export type TechArea = typeof TechArea.infer;
66
+
67
+ // Risk level for changes
68
+ export const RiskLevel = type("'low' | 'medium' | 'high' | 'critical'");
69
+ export type RiskLevel = typeof RiskLevel.infer;
70
+
71
+ // Tech stack analysis result (populated by Claude)
72
+ export const TechStackAnalysis = type({
73
+ "areas?": TechArea.array(),
74
+ "hasBreakingChange?": "boolean",
75
+ "riskLevel?": RiskLevel,
76
+ "affectedComponents?": "string[]",
77
+ "rationale?": "string",
78
+ "analyzedAt?": "string",
79
+ });
80
+ export type TechStackAnalysis = typeof TechStackAnalysis.infer;
81
+
45
82
  // Core Task schema
46
83
  export const Task = type({
47
84
  id: "string",
@@ -51,6 +88,10 @@ export const Task = type({
51
88
  priority: Priority,
52
89
  projectId: "string",
53
90
 
91
+ // Hierarchy (subtask support)
92
+ "parentId?": "string", // Parent task ID for subtasks
93
+ "level?": "number", // Hierarchy depth (0=root, 1=subtask, 2=sub-subtask, max 3)
94
+
54
95
  // Dependencies
55
96
  "dependencies?": Dependency.array(),
56
97
 
@@ -78,6 +119,10 @@ export const Task = type({
78
119
  "slack?": "number", // Minutes of slack time
79
120
  "earliestStart?": "number", // Minutes from project start
80
121
  "latestStart?": "number",
122
+
123
+ // Analysis fields (populated by Claude)
124
+ "complexity?": ComplexityAnalysis,
125
+ "techStack?": TechStackAnalysis,
81
126
  });
82
127
  export type Task = typeof Task.infer;
83
128
 
@@ -87,6 +132,7 @@ export const TaskCreateInput = type({
87
132
  "description?": "string",
88
133
  "projectId?": "string",
89
134
  "priority?": Priority,
135
+ "parentId?": "string", // Parent task ID for creating subtasks
90
136
  "dependencies?": Dependency.array(),
91
137
  "estimate?": TimeEstimate,
92
138
  "dueDate?": "string",
@@ -104,6 +150,7 @@ export const TaskUpdateInput = type({
104
150
  "status?": TaskStatus,
105
151
  "priority?": Priority,
106
152
  "projectId?": "string",
153
+ "parentId?": "string", // Parent task ID for moving task in hierarchy
107
154
  "dependencies?": Dependency.array(),
108
155
  "estimate?": TimeEstimate,
109
156
  "actualMinutes?": "number",
@@ -112,5 +159,8 @@ export const TaskUpdateInput = type({
112
159
  "contexts?": "string[]",
113
160
  "tags?": "string[]",
114
161
  "recurrence?": Recurrence,
162
+ // Analysis fields
163
+ "complexity?": ComplexityAnalysis,
164
+ "techStack?": TechStackAnalysis,
115
165
  });
116
166
  export type TaskUpdateInput = typeof TaskUpdateInput.infer;
package/src/utils/date.ts CHANGED
@@ -67,10 +67,26 @@ export function parseRelativeDate(input: string): Date | null {
67
67
  return d;
68
68
  }
69
69
 
70
- // Try parsing as ISO date
70
+ // Try parsing as YYYY-MM-DD format (local timezone, no UTC shift)
71
+ const isoDateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
72
+ if (isoDateMatch) {
73
+ const [, yearStr, monthStr, dayStr] = isoDateMatch;
74
+ const year = parseInt(yearStr!, 10);
75
+ const month = parseInt(monthStr!, 10) - 1; // 0-indexed
76
+ const day = parseInt(dayStr!, 10);
77
+ const d = new Date(year, month, day);
78
+ // Validate the date is valid (e.g., not Feb 30)
79
+ if (d.getFullYear() === year && d.getMonth() === month && d.getDate() === day) {
80
+ return d;
81
+ }
82
+ }
83
+
84
+ // Try parsing other date formats (fallback)
71
85
  const parsed = new Date(input);
72
86
  if (!isNaN(parsed.getTime())) {
73
- return parsed;
87
+ // For non-YYYY-MM-DD formats, normalize to local midnight
88
+ const d = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
89
+ return d;
74
90
  }
75
91
 
76
92
  return null;
@@ -0,0 +1,224 @@
1
+ import type { Task } from "../schemas/task.js";
2
+
3
+ /**
4
+ * Maximum allowed hierarchy depth (0-indexed)
5
+ * 0 = root task
6
+ * 1 = subtask
7
+ * 2 = sub-subtask
8
+ * 3 = sub-sub-subtask (maximum)
9
+ */
10
+ export const MAX_HIERARCHY_DEPTH = 3;
11
+
12
+ /**
13
+ * Calculates the hierarchy level of a task.
14
+ * Level 0 = root task (no parent)
15
+ * Level 1 = direct subtask
16
+ * Level 2 = sub-subtask
17
+ * etc.
18
+ *
19
+ * @param tasks - Array of all tasks
20
+ * @param taskId - ID of the task to get level for
21
+ * @returns The hierarchy level (0 for root), or -1 if task not found
22
+ */
23
+ export function getTaskLevel(tasks: Task[], taskId: string): number {
24
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
25
+ const task = taskMap.get(taskId);
26
+
27
+ if (!task) {
28
+ return -1;
29
+ }
30
+
31
+ let level = 0;
32
+ let currentTask = task;
33
+
34
+ while (currentTask.parentId) {
35
+ const parent = taskMap.get(currentTask.parentId);
36
+ if (!parent) {
37
+ break; // Parent not found, stop traversal
38
+ }
39
+ level++;
40
+ currentTask = parent;
41
+
42
+ // Safety check to prevent infinite loops
43
+ if (level > MAX_HIERARCHY_DEPTH + 1) {
44
+ break;
45
+ }
46
+ }
47
+
48
+ return level;
49
+ }
50
+
51
+ /**
52
+ * Validates whether adding a child task under the given parent
53
+ * would exceed the maximum hierarchy depth.
54
+ *
55
+ * @param tasks - Array of all tasks
56
+ * @param parentId - ID of the proposed parent task
57
+ * @returns true if a child can be added, false if it would exceed max depth
58
+ */
59
+ export function validateHierarchyDepth(
60
+ tasks: Task[],
61
+ parentId: string
62
+ ): boolean {
63
+ const parentLevel = getTaskLevel(tasks, parentId);
64
+
65
+ if (parentLevel === -1) {
66
+ return false; // Parent task not found
67
+ }
68
+
69
+ // Child would be at parentLevel + 1
70
+ // If parent is at level 3, child would be at level 4 which exceeds max
71
+ return parentLevel < MAX_HIERARCHY_DEPTH;
72
+ }
73
+
74
+ /**
75
+ * Gets all ancestor task IDs for a given task (from immediate parent to root).
76
+ *
77
+ * @param tasks - Array of all tasks
78
+ * @param taskId - ID of the task
79
+ * @returns Array of ancestor task IDs, ordered from immediate parent to root
80
+ */
81
+ export function getAncestorIds(tasks: Task[], taskId: string): string[] {
82
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
83
+ const task = taskMap.get(taskId);
84
+
85
+ if (!task) {
86
+ return [];
87
+ }
88
+
89
+ const ancestors: string[] = [];
90
+ let currentTask = task;
91
+
92
+ while (currentTask.parentId) {
93
+ ancestors.push(currentTask.parentId);
94
+ const parent = taskMap.get(currentTask.parentId);
95
+ if (!parent) {
96
+ break;
97
+ }
98
+ currentTask = parent;
99
+
100
+ // Safety check
101
+ if (ancestors.length > MAX_HIERARCHY_DEPTH + 1) {
102
+ break;
103
+ }
104
+ }
105
+
106
+ return ancestors;
107
+ }
108
+
109
+ /**
110
+ * Gets all descendant task IDs for a given task (children, grandchildren, etc.).
111
+ *
112
+ * @param tasks - Array of all tasks
113
+ * @param taskId - ID of the parent task
114
+ * @returns Array of descendant task IDs
115
+ */
116
+ export function getDescendantIds(tasks: Task[], taskId: string): string[] {
117
+ const descendants: string[] = [];
118
+ const childrenMap = new Map<string, Task[]>();
119
+
120
+ // Build parent -> children map
121
+ for (const task of tasks) {
122
+ if (task.parentId) {
123
+ const children = childrenMap.get(task.parentId) || [];
124
+ children.push(task);
125
+ childrenMap.set(task.parentId, children);
126
+ }
127
+ }
128
+
129
+ // BFS to collect all descendants
130
+ const queue = [taskId];
131
+ while (queue.length > 0) {
132
+ const currentId = queue.shift()!;
133
+ const children = childrenMap.get(currentId) || [];
134
+ for (const child of children) {
135
+ descendants.push(child.id);
136
+ queue.push(child.id);
137
+ }
138
+ }
139
+
140
+ return descendants;
141
+ }
142
+
143
+ /**
144
+ * Gets direct children of a task.
145
+ *
146
+ * @param tasks - Array of all tasks
147
+ * @param taskId - ID of the parent task
148
+ * @returns Array of direct child tasks
149
+ */
150
+ export function getChildTasks(tasks: Task[], taskId: string): Task[] {
151
+ return tasks.filter((t) => t.parentId === taskId);
152
+ }
153
+
154
+ /**
155
+ * Gets the root task of a hierarchy.
156
+ *
157
+ * @param tasks - Array of all tasks
158
+ * @param taskId - ID of any task in the hierarchy
159
+ * @returns The root task, or null if not found
160
+ */
161
+ export function getRootTask(tasks: Task[], taskId: string): Task | null {
162
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
163
+ let currentTask = taskMap.get(taskId);
164
+
165
+ if (!currentTask) {
166
+ return null;
167
+ }
168
+
169
+ while (currentTask.parentId) {
170
+ const parent = taskMap.get(currentTask.parentId);
171
+ if (!parent) {
172
+ break;
173
+ }
174
+ currentTask = parent;
175
+ }
176
+
177
+ return currentTask;
178
+ }
179
+
180
+ /**
181
+ * Builds a tree structure from flat task list.
182
+ *
183
+ * @param tasks - Array of all tasks
184
+ * @param rootId - Optional root task ID (if not provided, returns all root tasks)
185
+ * @returns Tree structure with children arrays
186
+ */
187
+ export interface TaskTreeNode {
188
+ task: Task;
189
+ children: TaskTreeNode[];
190
+ }
191
+
192
+ export function buildTaskTree(tasks: Task[], rootId?: string): TaskTreeNode[] {
193
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
194
+ const childrenMap = new Map<string, Task[]>();
195
+
196
+ // Build parent -> children map
197
+ for (const task of tasks) {
198
+ if (task.parentId) {
199
+ const children = childrenMap.get(task.parentId) || [];
200
+ children.push(task);
201
+ childrenMap.set(task.parentId, children);
202
+ }
203
+ }
204
+
205
+ function buildNode(task: Task): TaskTreeNode {
206
+ const children = childrenMap.get(task.id) || [];
207
+ return {
208
+ task,
209
+ children: children.map(buildNode),
210
+ };
211
+ }
212
+
213
+ if (rootId) {
214
+ const rootTask = taskMap.get(rootId);
215
+ if (!rootTask) {
216
+ return [];
217
+ }
218
+ return [buildNode(rootTask)];
219
+ }
220
+
221
+ // Return all root tasks (no parent)
222
+ const rootTasks = tasks.filter((t) => !t.parentId);
223
+ return rootTasks.map(buildNode);
224
+ }
@@ -0,0 +1,281 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ generateId,
4
+ generateTaskId,
5
+ generateProjectId,
6
+ generateInboxId,
7
+ isValidTaskId,
8
+ isValidProjectId,
9
+ isValidInboxId,
10
+ validateTaskId,
11
+ validateProjectId,
12
+ validateInboxId,
13
+ assertValidTaskId,
14
+ assertValidProjectId,
15
+ assertValidInboxId,
16
+ InvalidIdError,
17
+ } from "./id.js";
18
+
19
+ describe("ID Generation", () => {
20
+ describe("generateId", () => {
21
+ test("generates ID without prefix", () => {
22
+ const id = generateId();
23
+ expect(id.length).toBe(12);
24
+ expect(/^[a-z0-9]+$/.test(id)).toBe(true);
25
+ });
26
+
27
+ test("generates ID with prefix", () => {
28
+ const id = generateId("test");
29
+ expect(id).toMatch(/^test_[a-z0-9]+$/);
30
+ });
31
+
32
+ test("generates unique IDs", () => {
33
+ const ids = new Set(Array.from({ length: 100 }, () => generateId("test")));
34
+ expect(ids.size).toBe(100);
35
+ });
36
+ });
37
+
38
+ describe("generateTaskId", () => {
39
+ test("generates task ID with correct prefix", () => {
40
+ const id = generateTaskId();
41
+ expect(id).toMatch(/^task_[a-z0-9]+$/);
42
+ });
43
+ });
44
+
45
+ describe("generateProjectId", () => {
46
+ test("generates project ID with correct prefix", () => {
47
+ const id = generateProjectId();
48
+ expect(id).toMatch(/^proj_[a-z0-9]+$/);
49
+ });
50
+ });
51
+
52
+ describe("generateInboxId", () => {
53
+ test("generates inbox ID with correct prefix", () => {
54
+ const id = generateInboxId();
55
+ expect(id).toMatch(/^inbox_[a-z0-9]+$/);
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("Simple ID Validation (boolean)", () => {
61
+ describe("isValidTaskId", () => {
62
+ test("returns true for valid task IDs", () => {
63
+ expect(isValidTaskId("task_abc123")).toBe(true);
64
+ expect(isValidTaskId("task_a")).toBe(true);
65
+ expect(isValidTaskId("task_123")).toBe(true);
66
+ expect(isValidTaskId("task_abc123def456")).toBe(true);
67
+ });
68
+
69
+ test("returns false for invalid task IDs", () => {
70
+ expect(isValidTaskId("")).toBe(false);
71
+ expect(isValidTaskId("task_")).toBe(false);
72
+ expect(isValidTaskId("abc123")).toBe(false);
73
+ expect(isValidTaskId("proj_abc123")).toBe(false);
74
+ expect(isValidTaskId("task_ABC123")).toBe(false);
75
+ expect(isValidTaskId("task_abc-123")).toBe(false);
76
+ expect(isValidTaskId("task_abc.123")).toBe(false);
77
+ expect(isValidTaskId("task_abc/123")).toBe(false);
78
+ expect(isValidTaskId("task_abc 123")).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("isValidProjectId", () => {
83
+ test("returns true for valid project IDs", () => {
84
+ expect(isValidProjectId("proj_abc123")).toBe(true);
85
+ expect(isValidProjectId("proj_a")).toBe(true);
86
+ });
87
+
88
+ test("returns false for invalid project IDs", () => {
89
+ expect(isValidProjectId("")).toBe(false);
90
+ expect(isValidProjectId("project_abc123")).toBe(false);
91
+ expect(isValidProjectId("task_abc123")).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("isValidInboxId", () => {
96
+ test("returns true for valid inbox IDs", () => {
97
+ expect(isValidInboxId("inbox_abc123")).toBe(true);
98
+ expect(isValidInboxId("inbox_a")).toBe(true);
99
+ });
100
+
101
+ test("returns false for invalid inbox IDs", () => {
102
+ expect(isValidInboxId("")).toBe(false);
103
+ expect(isValidInboxId("in_abc123")).toBe(false);
104
+ expect(isValidInboxId("task_abc123")).toBe(false);
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("Detailed ID Validation", () => {
110
+ describe("validateTaskId", () => {
111
+ test("returns valid: true for valid task IDs", () => {
112
+ expect(validateTaskId("task_abc123")).toEqual({ valid: true });
113
+ expect(validateTaskId("task_a1b2c3")).toEqual({ valid: true });
114
+ });
115
+
116
+ test("returns error for null/undefined", () => {
117
+ const result = validateTaskId(null);
118
+ expect(result.valid).toBe(false);
119
+ expect(result.reason).toContain("required");
120
+
121
+ const result2 = validateTaskId(undefined);
122
+ expect(result2.valid).toBe(false);
123
+ });
124
+
125
+ test("returns error for non-string", () => {
126
+ const result = validateTaskId(123);
127
+ expect(result.valid).toBe(false);
128
+ expect(result.reason).toContain("string");
129
+ expect(result.reason).toContain("number");
130
+ });
131
+
132
+ test("returns error for empty string", () => {
133
+ const result = validateTaskId("");
134
+ expect(result.valid).toBe(false);
135
+ expect(result.reason).toContain("empty");
136
+ });
137
+
138
+ test("returns error for missing prefix", () => {
139
+ const result = validateTaskId("abc123");
140
+ expect(result.valid).toBe(false);
141
+ expect(result.reason).toContain("task_");
142
+ });
143
+
144
+ test("returns error for wrong prefix", () => {
145
+ const result = validateTaskId("proj_abc123");
146
+ expect(result.valid).toBe(false);
147
+ expect(result.reason).toContain("task_");
148
+ });
149
+
150
+ test("returns error for invalid characters", () => {
151
+ const result = validateTaskId("task_abc-123");
152
+ expect(result.valid).toBe(false);
153
+ expect(result.reason).toContain("invalid characters");
154
+ });
155
+ });
156
+
157
+ describe("validateProjectId", () => {
158
+ test("returns valid: true for valid project IDs", () => {
159
+ expect(validateProjectId("proj_abc123")).toEqual({ valid: true });
160
+ });
161
+
162
+ test("returns error for missing prefix", () => {
163
+ const result = validateProjectId("project_abc");
164
+ expect(result.valid).toBe(false);
165
+ expect(result.reason).toContain("proj_");
166
+ });
167
+ });
168
+
169
+ describe("validateInboxId", () => {
170
+ test("returns valid: true for valid inbox IDs", () => {
171
+ expect(validateInboxId("inbox_abc123")).toEqual({ valid: true });
172
+ });
173
+
174
+ test("returns error for missing prefix", () => {
175
+ const result = validateInboxId("in_abc");
176
+ expect(result.valid).toBe(false);
177
+ expect(result.reason).toContain("inbox_");
178
+ });
179
+ });
180
+ });
181
+
182
+ describe("Assert Functions", () => {
183
+ describe("assertValidTaskId", () => {
184
+ test("does not throw for valid ID", () => {
185
+ expect(() => assertValidTaskId("task_abc123")).not.toThrow();
186
+ });
187
+
188
+ test("throws InvalidIdError for invalid ID", () => {
189
+ expect(() => assertValidTaskId("")).toThrow(InvalidIdError);
190
+ expect(() => assertValidTaskId("abc123")).toThrow(InvalidIdError);
191
+ expect(() => assertValidTaskId(null)).toThrow(InvalidIdError);
192
+ });
193
+
194
+ test("InvalidIdError contains correct properties", () => {
195
+ try {
196
+ assertValidTaskId("invalid");
197
+ } catch (error) {
198
+ expect(error).toBeInstanceOf(InvalidIdError);
199
+ if (error instanceof InvalidIdError) {
200
+ expect(error.idType).toBe("task");
201
+ expect(error.invalidValue).toBe("invalid");
202
+ expect(error.reason).toBeDefined();
203
+ expect(error.message).toContain("task");
204
+ }
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("assertValidProjectId", () => {
210
+ test("does not throw for valid ID", () => {
211
+ expect(() => assertValidProjectId("proj_abc123")).not.toThrow();
212
+ });
213
+
214
+ test("throws InvalidIdError for invalid ID", () => {
215
+ expect(() => assertValidProjectId("project_abc")).toThrow(InvalidIdError);
216
+ });
217
+ });
218
+
219
+ describe("assertValidInboxId", () => {
220
+ test("does not throw for valid ID", () => {
221
+ expect(() => assertValidInboxId("inbox_abc123")).not.toThrow();
222
+ });
223
+
224
+ test("throws InvalidIdError for invalid ID", () => {
225
+ expect(() => assertValidInboxId("in_abc")).toThrow(InvalidIdError);
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("InvalidIdError", () => {
231
+ test("extends Error", () => {
232
+ const error = new InvalidIdError("task", "bad_id", "test reason");
233
+ expect(error).toBeInstanceOf(Error);
234
+ expect(error).toBeInstanceOf(InvalidIdError);
235
+ });
236
+
237
+ test("has correct name", () => {
238
+ const error = new InvalidIdError("task", "bad_id", "test reason");
239
+ expect(error.name).toBe("InvalidIdError");
240
+ });
241
+
242
+ test("has correct message format", () => {
243
+ const error = new InvalidIdError("task", "bad_id", "test reason");
244
+ expect(error.message).toBe("Invalid task ID: test reason");
245
+ });
246
+
247
+ test("exposes idType, invalidValue, and reason", () => {
248
+ const error = new InvalidIdError("project", "wrong", "missing prefix");
249
+ expect(error.idType).toBe("project");
250
+ expect(error.invalidValue).toBe("wrong");
251
+ expect(error.reason).toBe("missing prefix");
252
+ });
253
+ });
254
+
255
+ describe("Security: ID Injection Prevention", () => {
256
+ test("rejects path traversal attempts", () => {
257
+ expect(validateTaskId("task_../etc/passwd").valid).toBe(false);
258
+ expect(validateTaskId("task_..%2F..%2Fetc").valid).toBe(false);
259
+ });
260
+
261
+ test("rejects null bytes", () => {
262
+ expect(validateTaskId("task_abc\0def").valid).toBe(false);
263
+ });
264
+
265
+ test("rejects SQL injection attempts", () => {
266
+ expect(validateTaskId("task_'; DROP TABLE tasks;--").valid).toBe(false);
267
+ expect(validateTaskId("task_1 OR 1=1").valid).toBe(false);
268
+ });
269
+
270
+ test("rejects script injection attempts", () => {
271
+ expect(validateTaskId("task_<script>alert(1)</script>").valid).toBe(false);
272
+ });
273
+
274
+ test("only allows safe alphanumeric characters", () => {
275
+ // The regex ^task_[a-z0-9]+$ only allows lowercase letters and numbers
276
+ expect(validateTaskId("task_abc123").valid).toBe(true);
277
+ expect(validateTaskId("task_UPPERCASE").valid).toBe(false);
278
+ expect(validateTaskId("task_with_underscore").valid).toBe(false);
279
+ expect(validateTaskId("task_with-dash").valid).toBe(false);
280
+ });
281
+ });