@task-mcp/shared 1.0.4 → 1.0.7
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.
- package/dist/algorithms/critical-path.d.ts.map +1 -1
- package/dist/algorithms/critical-path.js +50 -26
- package/dist/algorithms/critical-path.js.map +1 -1
- package/dist/algorithms/dependency-integrity.d.ts +73 -0
- package/dist/algorithms/dependency-integrity.d.ts.map +1 -0
- package/dist/algorithms/dependency-integrity.js +189 -0
- package/dist/algorithms/dependency-integrity.js.map +1 -0
- package/dist/algorithms/index.d.ts +2 -0
- package/dist/algorithms/index.d.ts.map +1 -1
- package/dist/algorithms/index.js +2 -0
- package/dist/algorithms/index.js.map +1 -1
- package/dist/algorithms/tech-analysis.d.ts +106 -0
- package/dist/algorithms/tech-analysis.d.ts.map +1 -0
- package/dist/algorithms/tech-analysis.js +296 -0
- package/dist/algorithms/tech-analysis.js.map +1 -0
- package/dist/algorithms/tech-analysis.test.d.ts +2 -0
- package/dist/algorithms/tech-analysis.test.d.ts.map +1 -0
- package/dist/algorithms/tech-analysis.test.js +338 -0
- package/dist/algorithms/tech-analysis.test.js.map +1 -0
- package/dist/algorithms/topological-sort.d.ts.map +1 -1
- package/dist/algorithms/topological-sort.js +60 -8
- package/dist/algorithms/topological-sort.js.map +1 -1
- package/dist/schemas/inbox.d.ts +55 -0
- package/dist/schemas/inbox.d.ts.map +1 -0
- package/dist/schemas/inbox.js +25 -0
- package/dist/schemas/inbox.js.map +1 -0
- package/dist/schemas/index.d.ts +3 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +9 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/project.d.ts +154 -41
- package/dist/schemas/project.d.ts.map +1 -1
- package/dist/schemas/project.js +38 -33
- package/dist/schemas/project.js.map +1 -1
- package/dist/schemas/response-format.d.ts +80 -0
- package/dist/schemas/response-format.d.ts.map +1 -0
- package/dist/schemas/response-format.js +17 -0
- package/dist/schemas/response-format.js.map +1 -0
- package/dist/schemas/task.d.ts +592 -94
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/schemas/task.js +124 -64
- package/dist/schemas/task.js.map +1 -1
- package/dist/schemas/view.d.ts +128 -37
- package/dist/schemas/view.d.ts.map +1 -1
- package/dist/schemas/view.js +38 -24
- package/dist/schemas/view.js.map +1 -1
- package/dist/utils/date.d.ts.map +1 -1
- package/dist/utils/date.js +17 -2
- package/dist/utils/date.js.map +1 -1
- package/dist/utils/hierarchy.d.ts +75 -0
- package/dist/utils/hierarchy.d.ts.map +1 -0
- package/dist/utils/hierarchy.js +179 -0
- package/dist/utils/hierarchy.js.map +1 -0
- package/dist/utils/id.d.ts +51 -1
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +124 -4
- package/dist/utils/id.js.map +1 -1
- package/dist/utils/id.test.d.ts +2 -0
- package/dist/utils/id.test.d.ts.map +1 -0
- package/dist/utils/id.test.js +228 -0
- package/dist/utils/id.test.js.map +1 -0
- package/dist/utils/index.d.ts +4 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +7 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/natural-language.d.ts +45 -0
- package/dist/utils/natural-language.d.ts.map +1 -1
- package/dist/utils/natural-language.js +86 -0
- package/dist/utils/natural-language.js.map +1 -1
- package/dist/utils/projection.d.ts +65 -0
- package/dist/utils/projection.d.ts.map +1 -0
- package/dist/utils/projection.js +181 -0
- package/dist/utils/projection.js.map +1 -0
- package/dist/utils/projection.test.d.ts +2 -0
- package/dist/utils/projection.test.d.ts.map +1 -0
- package/dist/utils/projection.test.js +400 -0
- package/dist/utils/projection.test.js.map +1 -0
- package/package.json +2 -2
- package/src/algorithms/critical-path.ts +56 -24
- package/src/algorithms/dependency-integrity.ts +270 -0
- package/src/algorithms/index.ts +28 -0
- package/src/algorithms/tech-analysis.test.ts +413 -0
- package/src/algorithms/tech-analysis.ts +412 -0
- package/src/algorithms/topological-sort.ts +66 -9
- package/src/schemas/inbox.ts +32 -0
- package/src/schemas/index.ts +31 -0
- package/src/schemas/project.ts +43 -40
- package/src/schemas/response-format.ts +108 -0
- package/src/schemas/task.ts +145 -77
- package/src/schemas/view.ts +43 -33
- package/src/utils/date.ts +18 -2
- package/src/utils/hierarchy.ts +224 -0
- package/src/utils/id.test.ts +281 -0
- package/src/utils/id.ts +139 -4
- package/src/utils/index.ts +46 -2
- package/src/utils/natural-language.ts +113 -0
- package/src/utils/projection.test.ts +505 -0
- package/src/utils/projection.ts +251 -0
|
@@ -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
|
+
});
|
package/src/utils/id.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generate a unique ID with optional prefix
|
|
2
|
+
* Generate a unique ID with optional prefix.
|
|
3
|
+
* Uses crypto.randomUUID for secure, collision-resistant ID generation.
|
|
3
4
|
*/
|
|
4
5
|
export function generateId(prefix: string = ""): string {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Use crypto.randomUUID for better randomness and collision resistance
|
|
7
|
+
// Format: prefix_xxxxxxxx (8 chars from UUID, sufficient for local uniqueness)
|
|
8
|
+
const uuid = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
9
|
+
return prefix ? `${prefix}_${uuid}` : uuid;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -43,3 +45,136 @@ export function isValidProjectId(id: string): boolean {
|
|
|
43
45
|
export function isValidTaskId(id: string): boolean {
|
|
44
46
|
return /^task_[a-z0-9]+$/.test(id);
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generate an inbox item ID
|
|
51
|
+
*/
|
|
52
|
+
export function generateInboxId(): string {
|
|
53
|
+
return generateId("inbox");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate an inbox ID format
|
|
58
|
+
* @returns true if valid format (inbox_[alphanumeric])
|
|
59
|
+
*/
|
|
60
|
+
export function isValidInboxId(id: string): boolean {
|
|
61
|
+
return /^inbox_[a-z0-9]+$/.test(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ID validation error with detailed context
|
|
66
|
+
*/
|
|
67
|
+
export class InvalidIdError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
public readonly idType: "task" | "project" | "inbox" | "view",
|
|
70
|
+
public readonly invalidValue: unknown,
|
|
71
|
+
public readonly reason: string
|
|
72
|
+
) {
|
|
73
|
+
super(`Invalid ${idType} ID: ${reason}`);
|
|
74
|
+
this.name = "InvalidIdError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validation result with reason if invalid
|
|
80
|
+
*/
|
|
81
|
+
export interface IdValidationResult {
|
|
82
|
+
valid: boolean;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate task ID with detailed error information
|
|
88
|
+
*/
|
|
89
|
+
export function validateTaskId(id: unknown): IdValidationResult {
|
|
90
|
+
if (id === null || id === undefined) {
|
|
91
|
+
return { valid: false, reason: "Task ID is required" };
|
|
92
|
+
}
|
|
93
|
+
if (typeof id !== "string") {
|
|
94
|
+
return { valid: false, reason: `Task ID must be a string (received ${typeof id})` };
|
|
95
|
+
}
|
|
96
|
+
if (id.length === 0) {
|
|
97
|
+
return { valid: false, reason: "Task ID cannot be empty" };
|
|
98
|
+
}
|
|
99
|
+
if (!id.startsWith("task_")) {
|
|
100
|
+
return { valid: false, reason: "Task ID must start with 'task_' prefix" };
|
|
101
|
+
}
|
|
102
|
+
if (!/^task_[a-z0-9]+$/.test(id)) {
|
|
103
|
+
return { valid: false, reason: "Task ID contains invalid characters" };
|
|
104
|
+
}
|
|
105
|
+
return { valid: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate project ID with detailed error information
|
|
110
|
+
*/
|
|
111
|
+
export function validateProjectId(id: unknown): IdValidationResult {
|
|
112
|
+
if (id === null || id === undefined) {
|
|
113
|
+
return { valid: false, reason: "Project ID is required" };
|
|
114
|
+
}
|
|
115
|
+
if (typeof id !== "string") {
|
|
116
|
+
return { valid: false, reason: `Project ID must be a string (received ${typeof id})` };
|
|
117
|
+
}
|
|
118
|
+
if (id.length === 0) {
|
|
119
|
+
return { valid: false, reason: "Project ID cannot be empty" };
|
|
120
|
+
}
|
|
121
|
+
if (!id.startsWith("proj_")) {
|
|
122
|
+
return { valid: false, reason: "Project ID must start with 'proj_' prefix" };
|
|
123
|
+
}
|
|
124
|
+
if (!/^proj_[a-z0-9]+$/.test(id)) {
|
|
125
|
+
return { valid: false, reason: "Project ID contains invalid characters" };
|
|
126
|
+
}
|
|
127
|
+
return { valid: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate inbox ID with detailed error information
|
|
132
|
+
*/
|
|
133
|
+
export function validateInboxId(id: unknown): IdValidationResult {
|
|
134
|
+
if (id === null || id === undefined) {
|
|
135
|
+
return { valid: false, reason: "Inbox ID is required" };
|
|
136
|
+
}
|
|
137
|
+
if (typeof id !== "string") {
|
|
138
|
+
return { valid: false, reason: `Inbox ID must be a string (received ${typeof id})` };
|
|
139
|
+
}
|
|
140
|
+
if (id.length === 0) {
|
|
141
|
+
return { valid: false, reason: "Inbox ID cannot be empty" };
|
|
142
|
+
}
|
|
143
|
+
if (!id.startsWith("inbox_")) {
|
|
144
|
+
return { valid: false, reason: "Inbox ID must start with 'inbox_' prefix" };
|
|
145
|
+
}
|
|
146
|
+
if (!/^inbox_[a-z0-9]+$/.test(id)) {
|
|
147
|
+
return { valid: false, reason: "Inbox ID contains invalid characters" };
|
|
148
|
+
}
|
|
149
|
+
return { valid: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Assert valid task ID, throw if invalid
|
|
154
|
+
*/
|
|
155
|
+
export function assertValidTaskId(id: unknown): asserts id is string {
|
|
156
|
+
const result = validateTaskId(id);
|
|
157
|
+
if (!result.valid) {
|
|
158
|
+
throw new InvalidIdError("task", id, result.reason!);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Assert valid project ID, throw if invalid
|
|
164
|
+
*/
|
|
165
|
+
export function assertValidProjectId(id: unknown): asserts id is string {
|
|
166
|
+
const result = validateProjectId(id);
|
|
167
|
+
if (!result.valid) {
|
|
168
|
+
throw new InvalidIdError("project", id, result.reason!);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Assert valid inbox ID, throw if invalid
|
|
174
|
+
*/
|
|
175
|
+
export function assertValidInboxId(id: unknown): asserts id is string {
|
|
176
|
+
const result = validateInboxId(id);
|
|
177
|
+
if (!result.valid) {
|
|
178
|
+
throw new InvalidIdError("inbox", id, result.reason!);
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
generateId,
|
|
3
|
+
generateTaskId,
|
|
4
|
+
generateProjectId,
|
|
5
|
+
generateViewId,
|
|
6
|
+
isValidProjectId,
|
|
7
|
+
isValidTaskId,
|
|
8
|
+
generateInboxId,
|
|
9
|
+
isValidInboxId,
|
|
10
|
+
// Validation utilities with detailed error information
|
|
11
|
+
InvalidIdError,
|
|
12
|
+
type IdValidationResult,
|
|
13
|
+
validateTaskId,
|
|
14
|
+
validateProjectId,
|
|
15
|
+
validateInboxId,
|
|
16
|
+
assertValidTaskId,
|
|
17
|
+
assertValidProjectId,
|
|
18
|
+
assertValidInboxId,
|
|
19
|
+
} from "./id.js";
|
|
2
20
|
export { now, parseRelativeDate, formatDate, formatDisplayDate, isToday, isPastDue, isWithinDays } from "./date.js";
|
|
3
|
-
export { parseTaskInput } from "./natural-language.js";
|
|
21
|
+
export { parseTaskInput, parseInboxInput, parseInput, type ParseTarget, type ParsedInput } from "./natural-language.js";
|
|
22
|
+
export {
|
|
23
|
+
MAX_HIERARCHY_DEPTH,
|
|
24
|
+
getTaskLevel,
|
|
25
|
+
validateHierarchyDepth,
|
|
26
|
+
getAncestorIds,
|
|
27
|
+
getDescendantIds,
|
|
28
|
+
getChildTasks,
|
|
29
|
+
getRootTask,
|
|
30
|
+
buildTaskTree,
|
|
31
|
+
type TaskTreeNode,
|
|
32
|
+
} from "./hierarchy.js";
|
|
33
|
+
|
|
34
|
+
// Projection utilities (token optimization)
|
|
35
|
+
export {
|
|
36
|
+
projectTask,
|
|
37
|
+
projectTasks,
|
|
38
|
+
projectTasksPaginated,
|
|
39
|
+
projectProject,
|
|
40
|
+
projectProjects,
|
|
41
|
+
projectInboxItem,
|
|
42
|
+
projectInboxItems,
|
|
43
|
+
formatResponse,
|
|
44
|
+
applyPagination,
|
|
45
|
+
truncate,
|
|
46
|
+
summarizeList,
|
|
47
|
+
} from "./projection.js";
|