@da1z/chop 0.0.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.
- package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
- package/.claude/settings.local.json +12 -0
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/.devcontainer/Dockerfile +102 -0
- package/.devcontainer/devcontainer.json +58 -0
- package/.devcontainer/init-firewall.sh +137 -0
- package/.github/workflows/publish.yml +76 -0
- package/CLAUDE.md +44 -0
- package/README.md +15 -0
- package/index.ts +2 -0
- package/loop.sh +206 -0
- package/package.json +27 -0
- package/specs/chop.md +313 -0
- package/src/commands/add.ts +74 -0
- package/src/commands/archive.ts +72 -0
- package/src/commands/completion.ts +232 -0
- package/src/commands/done.ts +38 -0
- package/src/commands/edit.ts +228 -0
- package/src/commands/init.ts +72 -0
- package/src/commands/list.ts +48 -0
- package/src/commands/move.ts +92 -0
- package/src/commands/pop.ts +45 -0
- package/src/commands/purge.ts +41 -0
- package/src/commands/show.ts +32 -0
- package/src/commands/status.ts +43 -0
- package/src/config/paths.ts +61 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +41 -0
- package/src/models/id-generator.ts +39 -0
- package/src/models/task.ts +98 -0
- package/src/storage/file-lock.ts +124 -0
- package/src/storage/storage-resolver.ts +63 -0
- package/src/storage/task-store.ts +173 -0
- package/src/types.ts +42 -0
- package/src/utils/display.ts +139 -0
- package/src/utils/git.ts +80 -0
- package/src/utils/prompts.ts +88 -0
- package/tests/errors.test.ts +86 -0
- package/tests/models/id-generator.test.ts +46 -0
- package/tests/models/task.test.ts +186 -0
- package/tests/storage/file-lock.test.ts +152 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { generateTaskId, parseTaskId } from "../../src/models/id-generator.ts";
|
|
3
|
+
|
|
4
|
+
describe("generateTaskId", () => {
|
|
5
|
+
test("generates ID in correct format", () => {
|
|
6
|
+
const { id, newSequence } = generateTaskId(0);
|
|
7
|
+
|
|
8
|
+
// ID should be in format: 7-char-hash-sequence
|
|
9
|
+
expect(id).toMatch(/^[a-f0-9]{7}-\d+$/);
|
|
10
|
+
expect(newSequence).toBe(1);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("increments sequence number", () => {
|
|
14
|
+
const { newSequence: seq1 } = generateTaskId(5);
|
|
15
|
+
const { newSequence: seq2 } = generateTaskId(10);
|
|
16
|
+
|
|
17
|
+
expect(seq1).toBe(6);
|
|
18
|
+
expect(seq2).toBe(11);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("generates unique IDs", () => {
|
|
22
|
+
const ids = new Set<string>();
|
|
23
|
+
for (let i = 0; i < 100; i++) {
|
|
24
|
+
const { id } = generateTaskId(i);
|
|
25
|
+
ids.add(id);
|
|
26
|
+
}
|
|
27
|
+
expect(ids.size).toBe(100);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("parseTaskId", () => {
|
|
32
|
+
test("parses valid ID", () => {
|
|
33
|
+
const result = parseTaskId("a1b2c3d-42");
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
hash: "a1b2c3d",
|
|
36
|
+
sequence: 42,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns null for invalid ID", () => {
|
|
41
|
+
expect(parseTaskId("invalid")).toBeNull();
|
|
42
|
+
expect(parseTaskId("abc-1")).toBeNull(); // hash too short
|
|
43
|
+
expect(parseTaskId("a1b2c3d-")).toBeNull(); // missing sequence
|
|
44
|
+
expect(parseTaskId("a1b2c3d-abc")).toBeNull(); // non-numeric sequence
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createTask,
|
|
4
|
+
isBlocked,
|
|
5
|
+
findDependents,
|
|
6
|
+
findAllDependents,
|
|
7
|
+
findTaskById,
|
|
8
|
+
isValidStatus,
|
|
9
|
+
getNextAvailableTask,
|
|
10
|
+
} from "../../src/models/task.ts";
|
|
11
|
+
import type { Task } from "../../src/types.ts";
|
|
12
|
+
|
|
13
|
+
// Helper to create a mock task
|
|
14
|
+
function mockTask(overrides: Partial<Task> = {}): Task {
|
|
15
|
+
return {
|
|
16
|
+
id: "abc1234-1",
|
|
17
|
+
title: "Test Task",
|
|
18
|
+
status: "open",
|
|
19
|
+
dependsOn: [],
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
updatedAt: new Date().toISOString(),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("createTask", () => {
|
|
27
|
+
test("creates task with required fields", () => {
|
|
28
|
+
const { task, newSequence } = createTask(0, { title: "My Task" });
|
|
29
|
+
|
|
30
|
+
expect(task.title).toBe("My Task");
|
|
31
|
+
expect(task.status).toBe("open");
|
|
32
|
+
expect(task.dependsOn).toEqual([]);
|
|
33
|
+
expect(task.id).toMatch(/^[a-f0-9]{7}-1$/);
|
|
34
|
+
expect(newSequence).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("creates task with optional fields", () => {
|
|
38
|
+
const { task } = createTask(5, {
|
|
39
|
+
title: "My Task",
|
|
40
|
+
description: "A description",
|
|
41
|
+
dependsOn: ["abc1234-1"],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(task.description).toBe("A description");
|
|
45
|
+
expect(task.dependsOn).toEqual(["abc1234-1"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("creates task with draft status", () => {
|
|
49
|
+
const { task } = createTask(0, {
|
|
50
|
+
title: "Draft Task",
|
|
51
|
+
status: "draft",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(task.status).toBe("draft");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("creates task with open status by default", () => {
|
|
58
|
+
const { task } = createTask(0, { title: "Open Task" });
|
|
59
|
+
|
|
60
|
+
expect(task.status).toBe("open");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("isBlocked", () => {
|
|
65
|
+
test("returns false for task with no dependencies", () => {
|
|
66
|
+
const task = mockTask({ dependsOn: [] });
|
|
67
|
+
expect(isBlocked(task, [])).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns false when all dependencies are done", () => {
|
|
71
|
+
const dep = mockTask({ id: "dep1234-1", status: "done" });
|
|
72
|
+
const task = mockTask({ dependsOn: ["dep1234-1"] });
|
|
73
|
+
expect(isBlocked(task, [dep, task])).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns false when all dependencies are archived", () => {
|
|
77
|
+
const dep = mockTask({ id: "dep1234-1", status: "archived" });
|
|
78
|
+
const task = mockTask({ dependsOn: ["dep1234-1"] });
|
|
79
|
+
expect(isBlocked(task, [dep, task])).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns true when dependency is open", () => {
|
|
83
|
+
const dep = mockTask({ id: "dep1234-1", status: "open" });
|
|
84
|
+
const task = mockTask({ dependsOn: ["dep1234-1"] });
|
|
85
|
+
expect(isBlocked(task, [dep, task])).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns true when dependency is in-progress", () => {
|
|
89
|
+
const dep = mockTask({ id: "dep1234-1", status: "in-progress" });
|
|
90
|
+
const task = mockTask({ dependsOn: ["dep1234-1"] });
|
|
91
|
+
expect(isBlocked(task, [dep, task])).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("findDependents", () => {
|
|
96
|
+
test("finds direct dependents", () => {
|
|
97
|
+
const task1 = mockTask({ id: "task1-1" });
|
|
98
|
+
const task2 = mockTask({ id: "task2-2", dependsOn: ["task1-1"] });
|
|
99
|
+
const task3 = mockTask({ id: "task3-3", dependsOn: ["task1-1"] });
|
|
100
|
+
const task4 = mockTask({ id: "task4-4" });
|
|
101
|
+
|
|
102
|
+
const dependents = findDependents("task1-1", [task1, task2, task3, task4]);
|
|
103
|
+
|
|
104
|
+
expect(dependents).toHaveLength(2);
|
|
105
|
+
expect(dependents.map((t) => t.id)).toContain("task2-2");
|
|
106
|
+
expect(dependents.map((t) => t.id)).toContain("task3-3");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("findAllDependents", () => {
|
|
111
|
+
test("finds recursive dependents", () => {
|
|
112
|
+
const task1 = mockTask({ id: "task1-1" });
|
|
113
|
+
const task2 = mockTask({ id: "task2-2", dependsOn: ["task1-1"] });
|
|
114
|
+
const task3 = mockTask({ id: "task3-3", dependsOn: ["task2-2"] });
|
|
115
|
+
const task4 = mockTask({ id: "task4-4" });
|
|
116
|
+
|
|
117
|
+
const dependents = findAllDependents("task1-1", [task1, task2, task3, task4]);
|
|
118
|
+
|
|
119
|
+
expect(dependents).toHaveLength(2);
|
|
120
|
+
expect(dependents.map((t) => t.id)).toContain("task2-2");
|
|
121
|
+
expect(dependents.map((t) => t.id)).toContain("task3-3");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("findTaskById", () => {
|
|
126
|
+
test("finds exact match", () => {
|
|
127
|
+
const task = mockTask({ id: "abc1234-1" });
|
|
128
|
+
const result = findTaskById("abc1234-1", [task]);
|
|
129
|
+
expect(result).toBe(task);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("finds partial match", () => {
|
|
133
|
+
const task = mockTask({ id: "abc1234-1" });
|
|
134
|
+
const result = findTaskById("abc1234", [task]);
|
|
135
|
+
expect(result).toBe(task);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("returns undefined for no match", () => {
|
|
139
|
+
const task = mockTask({ id: "abc1234-1" });
|
|
140
|
+
const result = findTaskById("xyz9999-9", [task]);
|
|
141
|
+
expect(result).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("isValidStatus", () => {
|
|
146
|
+
test("returns true for valid statuses", () => {
|
|
147
|
+
expect(isValidStatus("draft")).toBe(true);
|
|
148
|
+
expect(isValidStatus("open")).toBe(true);
|
|
149
|
+
expect(isValidStatus("in-progress")).toBe(true);
|
|
150
|
+
expect(isValidStatus("done")).toBe(true);
|
|
151
|
+
expect(isValidStatus("archived")).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("returns false for invalid statuses", () => {
|
|
155
|
+
expect(isValidStatus("invalid")).toBe(false);
|
|
156
|
+
expect(isValidStatus("")).toBe(false);
|
|
157
|
+
expect(isValidStatus("OPEN")).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("getNextAvailableTask", () => {
|
|
162
|
+
test("returns first open unblocked task", () => {
|
|
163
|
+
const task1 = mockTask({ id: "task1-1", status: "done" });
|
|
164
|
+
const task2 = mockTask({ id: "task2-2", status: "open" });
|
|
165
|
+
const task3 = mockTask({ id: "task3-3", status: "open" });
|
|
166
|
+
|
|
167
|
+
const result = getNextAvailableTask([task1, task2, task3]);
|
|
168
|
+
expect(result?.id).toBe("task2-2");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("skips blocked tasks", () => {
|
|
172
|
+
const task1 = mockTask({ id: "task1-1", status: "open" });
|
|
173
|
+
const task2 = mockTask({ id: "task2-2", status: "open", dependsOn: ["task1-1"] });
|
|
174
|
+
|
|
175
|
+
const result = getNextAvailableTask([task1, task2]);
|
|
176
|
+
expect(result?.id).toBe("task1-1");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("returns undefined when no tasks available", () => {
|
|
180
|
+
const task1 = mockTask({ id: "task1-1", status: "done" });
|
|
181
|
+
const task2 = mockTask({ id: "task2-2", status: "in-progress" });
|
|
182
|
+
|
|
183
|
+
const result = getNextAvailableTask([task1, task2]);
|
|
184
|
+
expect(result).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { withLock } from "../../src/storage/file-lock.ts";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { LockError } from "../../src/errors.ts";
|
|
7
|
+
|
|
8
|
+
describe("withLock", () => {
|
|
9
|
+
const testLockPath = join(tmpdir(), `chop-test-${Date.now()}.lock`);
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
// Clean up any leftover lock files
|
|
13
|
+
try {
|
|
14
|
+
if (existsSync(testLockPath)) {
|
|
15
|
+
unlinkSync(testLockPath);
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// Ignore cleanup errors
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("acquires and releases lock", async () => {
|
|
23
|
+
let executed = false;
|
|
24
|
+
|
|
25
|
+
await withLock(testLockPath, async () => {
|
|
26
|
+
executed = true;
|
|
27
|
+
// Lock file should exist during operation
|
|
28
|
+
expect(existsSync(testLockPath)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(executed).toBe(true);
|
|
32
|
+
// Lock file should be released after operation
|
|
33
|
+
expect(existsSync(testLockPath)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("releases lock on error", async () => {
|
|
37
|
+
const testError = new Error("Test error");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await withLock(testLockPath, async () => {
|
|
41
|
+
throw testError;
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
expect(error).toBe(testError);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Lock should still be released
|
|
48
|
+
expect(existsSync(testLockPath)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns operation result", async () => {
|
|
52
|
+
const result = await withLock(testLockPath, async () => {
|
|
53
|
+
return { value: 42 };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result).toEqual({ value: 42 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("handles concurrent lock attempts", async () => {
|
|
60
|
+
const results: number[] = [];
|
|
61
|
+
|
|
62
|
+
// Start first lock operation
|
|
63
|
+
const op1 = withLock(testLockPath, async () => {
|
|
64
|
+
results.push(1);
|
|
65
|
+
await Bun.sleep(50);
|
|
66
|
+
results.push(2);
|
|
67
|
+
return "op1";
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Wait a bit then start second operation
|
|
71
|
+
await Bun.sleep(10);
|
|
72
|
+
const op2 = withLock(testLockPath, async () => {
|
|
73
|
+
results.push(3);
|
|
74
|
+
return "op2";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Both should complete
|
|
78
|
+
const [r1, r2] = await Promise.all([op1, op2]);
|
|
79
|
+
|
|
80
|
+
expect(r1).toBe("op1");
|
|
81
|
+
expect(r2).toBe("op2");
|
|
82
|
+
|
|
83
|
+
// Operations should be serialized
|
|
84
|
+
// op1 should start first, complete, then op2 runs
|
|
85
|
+
expect(results[0]).toBe(1);
|
|
86
|
+
expect(results[1]).toBe(2);
|
|
87
|
+
expect(results[2]).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("removes stale lock and acquires new lock", async () => {
|
|
91
|
+
// Create a stale lock file (timestamp from 2 minutes ago)
|
|
92
|
+
const staleLockInfo = {
|
|
93
|
+
pid: 99999,
|
|
94
|
+
timestamp: Date.now() - 120_000, // 2 minutes ago
|
|
95
|
+
};
|
|
96
|
+
writeFileSync(testLockPath, JSON.stringify(staleLockInfo));
|
|
97
|
+
|
|
98
|
+
let executed = false;
|
|
99
|
+
|
|
100
|
+
// Should detect stale lock, remove it, and acquire
|
|
101
|
+
await withLock(testLockPath, async () => {
|
|
102
|
+
executed = true;
|
|
103
|
+
expect(existsSync(testLockPath)).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(executed).toBe(true);
|
|
107
|
+
expect(existsSync(testLockPath)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("handles invalid lock file content as stale", async () => {
|
|
111
|
+
// Create a lock file with invalid JSON
|
|
112
|
+
writeFileSync(testLockPath, "invalid json content");
|
|
113
|
+
|
|
114
|
+
let executed = false;
|
|
115
|
+
|
|
116
|
+
// Should treat unparseable lock as stale and acquire
|
|
117
|
+
await withLock(testLockPath, async () => {
|
|
118
|
+
executed = true;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(executed).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("throws LockError when lock cannot be acquired after retries", async () => {
|
|
125
|
+
// Create a fresh (non-stale) lock file that won't be removed
|
|
126
|
+
const freshLockInfo = {
|
|
127
|
+
pid: process.pid,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
writeFileSync(testLockPath, JSON.stringify(freshLockInfo));
|
|
131
|
+
|
|
132
|
+
// Mock by making the lock file appear fresh every time by keeping it updated
|
|
133
|
+
const intervalId = setInterval(() => {
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(testLockPath, JSON.stringify({
|
|
136
|
+
pid: process.pid,
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
}));
|
|
139
|
+
} catch {
|
|
140
|
+
// Lock might be removed, ignore
|
|
141
|
+
}
|
|
142
|
+
}, 50);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await expect(withLock(testLockPath, async () => {
|
|
146
|
+
return "should not execute";
|
|
147
|
+
})).rejects.toThrow(LockError);
|
|
148
|
+
} finally {
|
|
149
|
+
clearInterval(intervalId);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@total-typescript/tsconfig/bundler/no-dom",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// Required for Bun's .ts extension imports
|
|
5
|
+
"allowImportingTsExtensions": true
|
|
6
|
+
},
|
|
7
|
+
"include": ["src/**/*.ts", "tests/**/*.ts", "index.ts"],
|
|
8
|
+
"exclude": ["node_modules", "dist"]
|
|
9
|
+
}
|