tlc-claude-code 1.8.4 → 2.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 (77) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/recall.md +87 -0
  4. package/.claude/commands/tlc/remember.md +71 -0
  5. package/CLAUDE.md +84 -201
  6. package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
  7. package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
  8. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
  9. package/dashboard-web/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/server/index.js +29 -4
  12. package/server/lib/bug-writer.js +204 -0
  13. package/server/lib/bug-writer.test.js +279 -0
  14. package/server/lib/claude-cascade.js +247 -0
  15. package/server/lib/claude-cascade.test.js +245 -0
  16. package/server/lib/context-injection.js +121 -0
  17. package/server/lib/context-injection.test.js +340 -0
  18. package/server/lib/conversation-chunker.js +320 -0
  19. package/server/lib/conversation-chunker.test.js +573 -0
  20. package/server/lib/embedding-client.js +160 -0
  21. package/server/lib/embedding-client.test.js +243 -0
  22. package/server/lib/global-config.js +198 -0
  23. package/server/lib/global-config.test.js +288 -0
  24. package/server/lib/inherited-search.js +184 -0
  25. package/server/lib/inherited-search.test.js +343 -0
  26. package/server/lib/memory-api.js +180 -0
  27. package/server/lib/memory-api.test.js +322 -0
  28. package/server/lib/memory-hooks-capture.test.js +350 -0
  29. package/server/lib/memory-hooks.js +101 -0
  30. package/server/lib/memory-inheritance.js +179 -0
  31. package/server/lib/memory-inheritance.test.js +360 -0
  32. package/server/lib/plan-parser.js +33 -7
  33. package/server/lib/plan-writer.js +196 -0
  34. package/server/lib/plan-writer.test.js +298 -0
  35. package/server/lib/project-scanner.js +267 -0
  36. package/server/lib/project-scanner.test.js +389 -0
  37. package/server/lib/project-status.js +302 -0
  38. package/server/lib/project-status.test.js +470 -0
  39. package/server/lib/projects-registry.js +237 -0
  40. package/server/lib/projects-registry.test.js +275 -0
  41. package/server/lib/recall-command.js +207 -0
  42. package/server/lib/recall-command.test.js +306 -0
  43. package/server/lib/remember-command.js +96 -0
  44. package/server/lib/remember-command.test.js +265 -0
  45. package/server/lib/rich-capture.js +221 -0
  46. package/server/lib/rich-capture.test.js +312 -0
  47. package/server/lib/roadmap-api.js +200 -0
  48. package/server/lib/roadmap-api.test.js +318 -0
  49. package/server/lib/semantic-recall.js +242 -0
  50. package/server/lib/semantic-recall.test.js +446 -0
  51. package/server/lib/setup-generator.js +315 -0
  52. package/server/lib/setup-generator.test.js +303 -0
  53. package/server/lib/test-inventory.js +112 -0
  54. package/server/lib/test-inventory.test.js +360 -0
  55. package/server/lib/vector-indexer.js +246 -0
  56. package/server/lib/vector-indexer.test.js +459 -0
  57. package/server/lib/vector-store.js +260 -0
  58. package/server/lib/vector-store.test.js +706 -0
  59. package/server/lib/workspace-api.js +811 -0
  60. package/server/lib/workspace-api.test.js +743 -0
  61. package/server/lib/workspace-bootstrap.js +164 -0
  62. package/server/lib/workspace-bootstrap.test.js +503 -0
  63. package/server/lib/workspace-context.js +129 -0
  64. package/server/lib/workspace-context.test.js +214 -0
  65. package/server/lib/workspace-detector.js +162 -0
  66. package/server/lib/workspace-detector.test.js +193 -0
  67. package/server/lib/workspace-init.js +307 -0
  68. package/server/lib/workspace-init.test.js +244 -0
  69. package/server/lib/workspace-snapshot.js +236 -0
  70. package/server/lib/workspace-snapshot.test.js +444 -0
  71. package/server/lib/workspace-watcher.js +162 -0
  72. package/server/lib/workspace-watcher.test.js +257 -0
  73. package/server/package-lock.json +552 -0
  74. package/server/package.json +4 -0
  75. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  76. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  77. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -9,8 +9,8 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com" />
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
12
- <script type="module" crossorigin src="/assets/index-B1I_joSL.js"></script>
13
- <link rel="stylesheet" crossorigin href="/assets/index-Trhg1C1Y.css">
12
+ <script type="module" crossorigin src="/assets/index-W36XHPC5.js"></script>
13
+ <link rel="stylesheet" crossorigin href="/assets/index-Uhc49PE-.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.8.4",
3
+ "version": "2.0.1",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",
package/server/index.js CHANGED
@@ -21,6 +21,9 @@ const chokidar = require('chokidar');
21
21
  const { detectProject } = require('./lib/project-detector');
22
22
  const { parsePlan, parseBugs } = require('./lib/plan-parser');
23
23
  const { autoProvision, stopDatabase } = require('./lib/auto-database');
24
+ const { GlobalConfig } = require('./lib/global-config');
25
+ const { ProjectScanner } = require('./lib/project-scanner');
26
+ const { createWorkspaceRouter } = require('./lib/workspace-api');
24
27
  const {
25
28
  createUserStore,
26
29
  createAuthMiddleware,
@@ -71,6 +74,16 @@ const wss = new WebSocketServer({ server });
71
74
  app.use(express.json());
72
75
  const cookieParser = require('cookie-parser');
73
76
  app.use(cookieParser());
77
+ const cors = require('cors');
78
+ app.use(cors({ origin: true, credentials: true }));
79
+
80
+ // Workspace API
81
+ const globalConfig = new GlobalConfig();
82
+ const projectScanner = new ProjectScanner();
83
+ const workspaceRouter = createWorkspaceRouter({ globalConfig, projectScanner });
84
+ app.use('/api/workspace', workspaceRouter);
85
+ // Also mount project-level routes at /api/projects for per-project endpoints
86
+ app.use('/api', workspaceRouter);
74
87
 
75
88
  // ============================================
76
89
  // Authentication Setup
@@ -649,10 +662,22 @@ app.get('/api/project', (req, res) => {
649
662
  const roadmapPath = path.join(PROJECT_DIR, '.planning', 'ROADMAP.md');
650
663
  if (fs.existsSync(roadmapPath)) {
651
664
  const content = fs.readFileSync(roadmapPath, 'utf-8');
652
- const phases = content.match(/##\s+Phase\s+\d+/g) || [];
653
- totalPhases = phases.length;
654
- const completed = content.match(/##\s+Phase\s+\d+[^[]*\[x\]/gi) || [];
655
- completedPhases = completed.length;
665
+
666
+ // Format 1: ## Phase N heading format
667
+ const headingPhases = content.match(/##\s+Phase\s+\d+/g) || [];
668
+ const headingCompleted = content.match(/##\s+Phase\s+\d+[^[]*\[x\]/gi) || [];
669
+
670
+ // Format 2: Table format | N | [Name](link) | status |
671
+ const tablePhases = content.match(/\|\s*\d+\s*\|\s*\[[^\]]+\][^\|]*\|\s*\w+\s*\|/g) || [];
672
+ const tableCompleted = (content.match(/\|\s*\d+\s*\|\s*\[[^\]]+\][^\|]*\|\s*(?:complete|done|verified)\s*\|/gi) || []);
673
+
674
+ if (headingPhases.length > 0) {
675
+ totalPhases = headingPhases.length;
676
+ completedPhases = headingCompleted.length;
677
+ } else if (tablePhases.length > 0) {
678
+ totalPhases = tablePhases.length;
679
+ completedPhases = tableCompleted.length;
680
+ }
656
681
  }
657
682
 
658
683
  // Calculate progress
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Bug Writer - CRUD operations for bugs in BUGS.md files
3
+ *
4
+ * Provides functions to update bug status, content, and create new bugs.
5
+ * All writes are atomic (write to temp file, then rename).
6
+ *
7
+ * Uses dependency injection for fs to enable testability.
8
+ */
9
+
10
+ /**
11
+ * Create a bug writer with injected dependencies
12
+ * @param {object} deps
13
+ * @param {object} deps.fs - Node.js fs module (or mock)
14
+ * @returns {{ updateBugStatus, updateBugContent, createBug }}
15
+ */
16
+ function createBugWriter({ fs }) {
17
+ /**
18
+ * Write content atomically: write to .tmp, then rename
19
+ */
20
+ function atomicWrite(filePath, content) {
21
+ const tmpPath = filePath + '.tmp';
22
+ fs.writeFileSync(tmpPath, content, 'utf-8');
23
+ fs.renameSync(tmpPath, filePath);
24
+ }
25
+
26
+ /**
27
+ * Find all bug headings in BUGS.md content
28
+ */
29
+ function findBugs(content) {
30
+ const bugs = [];
31
+ const regex = /###\s+(BUG-\d+):\s+(.+?)\s*\[(\w+)\]/g;
32
+ let match;
33
+ while ((match = regex.exec(content)) !== null) {
34
+ bugs.push({
35
+ id: match[1],
36
+ title: match[2].trim(),
37
+ status: match[3],
38
+ fullMatch: match[0],
39
+ index: match.index,
40
+ });
41
+ }
42
+ return bugs;
43
+ }
44
+
45
+ /**
46
+ * Get the section of content belonging to a specific bug
47
+ */
48
+ function getBugSection(content, bugIndex, bugs) {
49
+ const start = bugIndex;
50
+ // Find end: next bug heading or end of content
51
+ const afterStart = content.slice(start + 1);
52
+ const nextBugMatch = afterStart.match(/\n###\s+BUG-/);
53
+ const end = nextBugMatch ? start + 1 + nextBugMatch.index : content.length;
54
+ return { start, end, section: content.slice(start, end) };
55
+ }
56
+
57
+ /**
58
+ * Update a bug's status in BUGS.md
59
+ * @param {string} bugsPath - Path to BUGS.md file
60
+ * @param {string} bugId - Bug ID (e.g., 'BUG-001')
61
+ * @param {string} newStatus - 'open' | 'fixed' | 'closed'
62
+ */
63
+ function updateBugStatus(bugsPath, bugId, newStatus) {
64
+ const content = fs.readFileSync(bugsPath, 'utf-8');
65
+ const bugs = findBugs(content);
66
+ const bug = bugs.find((b) => b.id === bugId);
67
+
68
+ if (!bug) {
69
+ throw new Error(`Bug ${bugId} not found in ${bugsPath}`);
70
+ }
71
+
72
+ const newHeading = `### ${bugId}: ${bug.title} [${newStatus}]`;
73
+ const updated = content.replace(bug.fullMatch, newHeading);
74
+
75
+ atomicWrite(bugsPath, updated);
76
+ }
77
+
78
+ /**
79
+ * Update a bug's content (title, severity, description)
80
+ * @param {string} bugsPath - Path to BUGS.md file
81
+ * @param {string} bugId - Bug ID (e.g., 'BUG-001')
82
+ * @param {object} updates - { title?, severity?, description? }
83
+ */
84
+ function updateBugContent(bugsPath, bugId, updates) {
85
+ let content = fs.readFileSync(bugsPath, 'utf-8');
86
+ const bugs = findBugs(content);
87
+ const bug = bugs.find((b) => b.id === bugId);
88
+
89
+ if (!bug) {
90
+ throw new Error(`Bug ${bugId} not found in ${bugsPath}`);
91
+ }
92
+
93
+ // Update title
94
+ if (updates.title) {
95
+ const newHeading = `### ${bugId}: ${updates.title} [${bug.status}]`;
96
+ content = content.replace(bug.fullMatch, newHeading);
97
+ }
98
+
99
+ // Update severity
100
+ if (updates.severity) {
101
+ const { start, end, section } = getBugSection(content, bug.index, bugs);
102
+ const newSection = section.replace(
103
+ /\*\*Severity:\*\*\s*\w+/,
104
+ `**Severity:** ${updates.severity}`
105
+ );
106
+ content = content.slice(0, start) + newSection + content.slice(end);
107
+ }
108
+
109
+ // Update description - replace content after metadata lines
110
+ if (updates.description) {
111
+ // Re-find bug position after potential title/severity changes
112
+ const updatedBugs = findBugs(content);
113
+ const updatedBug = updatedBugs.find((b) => b.id === bugId);
114
+ if (updatedBug) {
115
+ const { start, end, section } = getBugSection(content, updatedBug.index, updatedBugs);
116
+ // Find the end of metadata (after **Reported:** line)
117
+ const lines = section.split('\n');
118
+ let descStart = -1;
119
+ for (let i = 0; i < lines.length; i++) {
120
+ if (lines[i].startsWith('**Reported:**') || lines[i].startsWith('**Severity:**')) {
121
+ continue;
122
+ }
123
+ if (i > 1 && lines[i].trim() === '') {
124
+ continue;
125
+ }
126
+ if (i > 2 && lines[i].trim() !== '' && !lines[i].startsWith('**') && !lines[i].startsWith('###')) {
127
+ descStart = i;
128
+ break;
129
+ }
130
+ }
131
+
132
+ if (descStart >= 0) {
133
+ // Find end of description (before --- or next heading)
134
+ let descEnd = lines.length;
135
+ for (let i = descStart; i < lines.length; i++) {
136
+ if (lines[i].trim() === '---' || lines[i].startsWith('###')) {
137
+ descEnd = i;
138
+ break;
139
+ }
140
+ }
141
+ // Replace description lines
142
+ const newLines = [...lines.slice(0, descStart), updates.description, ...lines.slice(descEnd)];
143
+ const newSection = newLines.join('\n');
144
+ content = content.slice(0, start) + newSection + content.slice(end);
145
+ }
146
+ }
147
+ }
148
+
149
+ atomicWrite(bugsPath, content);
150
+ }
151
+
152
+ /**
153
+ * Create a new bug in BUGS.md
154
+ * @param {string} bugsPath - Path to BUGS.md file
155
+ * @param {object} bugData - { title, severity, description, url?, screenshot? }
156
+ * @returns {{ id: string, title: string, status: string }}
157
+ */
158
+ function createBug(bugsPath, bugData) {
159
+ let content;
160
+ try {
161
+ content = fs.readFileSync(bugsPath, 'utf-8');
162
+ } catch {
163
+ content = '# Bugs\n';
164
+ }
165
+
166
+ const bugs = findBugs(content);
167
+ const maxNum = bugs.reduce((max, b) => {
168
+ const num = parseInt(b.id.replace('BUG-', ''));
169
+ return num > max ? num : max;
170
+ }, 0);
171
+ const nextNum = maxNum + 1;
172
+ const bugId = `BUG-${String(nextNum).padStart(3, '0')}`;
173
+
174
+ const today = new Date().toISOString().split('T')[0];
175
+
176
+ const lines = [];
177
+ lines.push('');
178
+ lines.push(`### ${bugId}: ${bugData.title} [open]`);
179
+ lines.push('');
180
+ lines.push(`**Severity:** ${bugData.severity}`);
181
+ lines.push(`**Reported:** ${today}`);
182
+ if (bugData.url) {
183
+ lines.push(`**URL:** ${bugData.url}`);
184
+ }
185
+ if (bugData.screenshot) {
186
+ lines.push(`**Screenshot:** ${bugData.screenshot}`);
187
+ }
188
+ lines.push('');
189
+ lines.push(bugData.description);
190
+ lines.push('');
191
+ lines.push('---');
192
+ lines.push('');
193
+
194
+ content = content.trimEnd() + '\n' + lines.join('\n');
195
+
196
+ atomicWrite(bugsPath, content);
197
+
198
+ return { id: bugId, title: bugData.title, status: 'open' };
199
+ }
200
+
201
+ return { updateBugStatus, updateBugContent, createBug };
202
+ }
203
+
204
+ module.exports = { createBugWriter };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @file bug-writer.test.js
3
+ * @description Tests for the Bug Writer module (Phase 76, Task 6).
4
+ *
5
+ * Tests the factory function `createBugWriter(deps)` which accepts injected
6
+ * dependencies (fs) and returns functions for updating bug status and content
7
+ * in BUGS.md files.
8
+ *
9
+ * TDD: RED phase — these tests are written BEFORE the implementation.
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { createBugWriter } from './bug-writer.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock factories
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function createMockFs(files = {}) {
19
+ const store = { ...files };
20
+ return {
21
+ existsSync: vi.fn((p) => p in store),
22
+ readFileSync: vi.fn((p) => {
23
+ if (p in store) return store[p];
24
+ throw new Error(`ENOENT: no such file or directory, open '${p}'`);
25
+ }),
26
+ writeFileSync: vi.fn((p, content) => {
27
+ store[p] = content;
28
+ }),
29
+ renameSync: vi.fn((src, dest) => {
30
+ if (src in store) {
31
+ store[dest] = store[src];
32
+ delete store[src];
33
+ }
34
+ }),
35
+ mkdirSync: vi.fn(),
36
+ _store: store,
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Sample BUGS.md content
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const SAMPLE_BUGS = `# Bugs
45
+
46
+ ### BUG-001: Login page crashes on empty email [open]
47
+
48
+ **Severity:** high
49
+ **Reported:** 2026-02-10
50
+
51
+ Steps to reproduce:
52
+ 1. Go to login page
53
+ 2. Click submit without entering email
54
+ 3. Page crashes with TypeError
55
+
56
+ ---
57
+
58
+ ### BUG-002: Dashboard loads slowly [fixed]
59
+
60
+ **Severity:** medium
61
+ **Reported:** 2026-02-08
62
+
63
+ The dashboard takes 5+ seconds to load.
64
+
65
+ ---
66
+
67
+ ### BUG-003: Sidebar menu overlaps content on mobile [open]
68
+
69
+ **Severity:** low
70
+ **Reported:** 2026-02-12
71
+
72
+ On iPhone 12 the sidebar pushes content off screen.
73
+
74
+ ---
75
+ `;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tests
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('bug-writer', () => {
82
+ describe('updateBugStatus', () => {
83
+ it('changes [open] to [fixed] in heading', () => {
84
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
85
+ const writer = createBugWriter({ fs: mockFs });
86
+
87
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-001', 'fixed');
88
+
89
+ const updated = mockFs._store['/project/BUGS.md'];
90
+ expect(updated).toContain('### BUG-001: Login page crashes on empty email [fixed]');
91
+ expect(updated).not.toContain('BUG-001: Login page crashes on empty email [open]');
92
+ });
93
+
94
+ it('changes [open] to [closed]', () => {
95
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
96
+ const writer = createBugWriter({ fs: mockFs });
97
+
98
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-003', 'closed');
99
+
100
+ const updated = mockFs._store['/project/BUGS.md'];
101
+ expect(updated).toContain('### BUG-003: Sidebar menu overlaps content on mobile [closed]');
102
+ });
103
+
104
+ it('changes [fixed] to [open] (reopen)', () => {
105
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
106
+ const writer = createBugWriter({ fs: mockFs });
107
+
108
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-002', 'open');
109
+
110
+ const updated = mockFs._store['/project/BUGS.md'];
111
+ expect(updated).toContain('### BUG-002: Dashboard loads slowly [open]');
112
+ });
113
+
114
+ it('preserves other bugs when updating one', () => {
115
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
116
+ const writer = createBugWriter({ fs: mockFs });
117
+
118
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-001', 'fixed');
119
+
120
+ const updated = mockFs._store['/project/BUGS.md'];
121
+ expect(updated).toContain('### BUG-002: Dashboard loads slowly [fixed]');
122
+ expect(updated).toContain('### BUG-003: Sidebar menu overlaps content on mobile [open]');
123
+ });
124
+
125
+ it('writes atomically (temp file + rename)', () => {
126
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
127
+ const writer = createBugWriter({ fs: mockFs });
128
+
129
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-001', 'fixed');
130
+
131
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
132
+ expect.stringContaining('.tmp'),
133
+ expect.any(String),
134
+ 'utf-8'
135
+ );
136
+ expect(mockFs.renameSync).toHaveBeenCalled();
137
+ });
138
+
139
+ it('throws for invalid bug ID', () => {
140
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
141
+ const writer = createBugWriter({ fs: mockFs });
142
+
143
+ expect(() => {
144
+ writer.updateBugStatus('/project/BUGS.md', 'BUG-999', 'fixed');
145
+ }).toThrow(/bug.*BUG-999.*not found/i);
146
+ });
147
+ });
148
+
149
+ describe('updateBugContent', () => {
150
+ it('updates bug title', () => {
151
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
152
+ const writer = createBugWriter({ fs: mockFs });
153
+
154
+ writer.updateBugContent('/project/BUGS.md', 'BUG-001', {
155
+ title: 'Login crashes on empty form submission',
156
+ });
157
+
158
+ const updated = mockFs._store['/project/BUGS.md'];
159
+ expect(updated).toContain('### BUG-001: Login crashes on empty form submission [open]');
160
+ });
161
+
162
+ it('updates severity line', () => {
163
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
164
+ const writer = createBugWriter({ fs: mockFs });
165
+
166
+ writer.updateBugContent('/project/BUGS.md', 'BUG-001', {
167
+ severity: 'critical',
168
+ });
169
+
170
+ const updated = mockFs._store['/project/BUGS.md'];
171
+ expect(updated).toContain('**Severity:** critical');
172
+ // Verify the old severity is replaced, not duplicated
173
+ expect(updated.match(/\*\*Severity:\*\*/g)?.length).toBe(3); // 3 bugs, each has severity
174
+ });
175
+
176
+ it('updates description', () => {
177
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
178
+ const writer = createBugWriter({ fs: mockFs });
179
+
180
+ writer.updateBugContent('/project/BUGS.md', 'BUG-002', {
181
+ description: 'Dashboard API calls take too long due to N+1 queries.',
182
+ });
183
+
184
+ const updated = mockFs._store['/project/BUGS.md'];
185
+ expect(updated).toContain('Dashboard API calls take too long due to N+1 queries.');
186
+ });
187
+
188
+ it('throws for invalid bug ID', () => {
189
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
190
+ const writer = createBugWriter({ fs: mockFs });
191
+
192
+ expect(() => {
193
+ writer.updateBugContent('/project/BUGS.md', 'BUG-999', { title: 'New' });
194
+ }).toThrow(/bug.*BUG-999.*not found/i);
195
+ });
196
+ });
197
+
198
+ describe('createBug', () => {
199
+ it('appends new bug with correct format', () => {
200
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
201
+ const writer = createBugWriter({ fs: mockFs });
202
+
203
+ const result = writer.createBug('/project/BUGS.md', {
204
+ title: 'Button color wrong on hover',
205
+ severity: 'low',
206
+ description: 'The primary button turns grey instead of blue on hover.',
207
+ url: 'http://localhost:3000/settings',
208
+ });
209
+
210
+ const updated = mockFs._store['/project/BUGS.md'];
211
+ expect(updated).toContain('### BUG-004: Button color wrong on hover [open]');
212
+ expect(updated).toContain('**Severity:** low');
213
+ expect(updated).toContain('**URL:** http://localhost:3000/settings');
214
+ expect(updated).toContain('The primary button turns grey instead of blue on hover.');
215
+ expect(result.id).toBe('BUG-004');
216
+ });
217
+
218
+ it('generates next bug ID correctly', () => {
219
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
220
+ const writer = createBugWriter({ fs: mockFs });
221
+
222
+ const result = writer.createBug('/project/BUGS.md', {
223
+ title: 'New bug',
224
+ severity: 'medium',
225
+ description: 'Something is wrong.',
226
+ });
227
+
228
+ expect(result.id).toBe('BUG-004');
229
+ });
230
+
231
+ it('creates bug in empty BUGS.md', () => {
232
+ const mockFs = createMockFs({ '/project/BUGS.md': '# Bugs\n' });
233
+ const writer = createBugWriter({ fs: mockFs });
234
+
235
+ const result = writer.createBug('/project/BUGS.md', {
236
+ title: 'First bug',
237
+ severity: 'high',
238
+ description: 'The first reported bug.',
239
+ });
240
+
241
+ const updated = mockFs._store['/project/BUGS.md'];
242
+ expect(updated).toContain('### BUG-001: First bug [open]');
243
+ expect(result.id).toBe('BUG-001');
244
+ });
245
+
246
+ it('creates BUGS.md if it does not exist', () => {
247
+ const mockFs = createMockFs({});
248
+ const writer = createBugWriter({ fs: mockFs });
249
+
250
+ const result = writer.createBug('/project/BUGS.md', {
251
+ title: 'First bug ever',
252
+ severity: 'medium',
253
+ description: 'No BUGS.md existed.',
254
+ });
255
+
256
+ expect(mockFs._store['/project/BUGS.md']).toBeDefined();
257
+ expect(mockFs._store['/project/BUGS.md']).toContain('### BUG-001: First bug ever [open]');
258
+ expect(result.id).toBe('BUG-001');
259
+ });
260
+
261
+ it('writes atomically', () => {
262
+ const mockFs = createMockFs({ '/project/BUGS.md': SAMPLE_BUGS });
263
+ const writer = createBugWriter({ fs: mockFs });
264
+
265
+ writer.createBug('/project/BUGS.md', {
266
+ title: 'New bug',
267
+ severity: 'low',
268
+ description: 'desc',
269
+ });
270
+
271
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
272
+ expect.stringContaining('.tmp'),
273
+ expect.any(String),
274
+ 'utf-8'
275
+ );
276
+ expect(mockFs.renameSync).toHaveBeenCalled();
277
+ });
278
+ });
279
+ });