@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.
Files changed (42) hide show
  1. package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
  2. package/.claude/settings.local.json +12 -0
  3. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  4. package/.devcontainer/Dockerfile +102 -0
  5. package/.devcontainer/devcontainer.json +58 -0
  6. package/.devcontainer/init-firewall.sh +137 -0
  7. package/.github/workflows/publish.yml +76 -0
  8. package/CLAUDE.md +44 -0
  9. package/README.md +15 -0
  10. package/index.ts +2 -0
  11. package/loop.sh +206 -0
  12. package/package.json +27 -0
  13. package/specs/chop.md +313 -0
  14. package/src/commands/add.ts +74 -0
  15. package/src/commands/archive.ts +72 -0
  16. package/src/commands/completion.ts +232 -0
  17. package/src/commands/done.ts +38 -0
  18. package/src/commands/edit.ts +228 -0
  19. package/src/commands/init.ts +72 -0
  20. package/src/commands/list.ts +48 -0
  21. package/src/commands/move.ts +92 -0
  22. package/src/commands/pop.ts +45 -0
  23. package/src/commands/purge.ts +41 -0
  24. package/src/commands/show.ts +32 -0
  25. package/src/commands/status.ts +43 -0
  26. package/src/config/paths.ts +61 -0
  27. package/src/errors.ts +56 -0
  28. package/src/index.ts +41 -0
  29. package/src/models/id-generator.ts +39 -0
  30. package/src/models/task.ts +98 -0
  31. package/src/storage/file-lock.ts +124 -0
  32. package/src/storage/storage-resolver.ts +63 -0
  33. package/src/storage/task-store.ts +173 -0
  34. package/src/types.ts +42 -0
  35. package/src/utils/display.ts +139 -0
  36. package/src/utils/git.ts +80 -0
  37. package/src/utils/prompts.ts +88 -0
  38. package/tests/errors.test.ts +86 -0
  39. package/tests/models/id-generator.test.ts +46 -0
  40. package/tests/models/task.test.ts +186 -0
  41. package/tests/storage/file-lock.test.ts +152 -0
  42. 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
+ }