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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +29 -4
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-parser.js +33 -7
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +96 -0
- package/server/lib/remember-command.test.js +265 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +552 -0
- package/server/package.json +4 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file project-status.test.js
|
|
3
|
+
* @description Tests for the Project Status module (Phase 75, Task 1).
|
|
4
|
+
*
|
|
5
|
+
* Tests the factory function `createProjectStatus(deps)` which accepts injected
|
|
6
|
+
* dependencies (fs, execSync) and returns `{ getFullStatus(projectPath) }`.
|
|
7
|
+
*
|
|
8
|
+
* The module parses a project's `.planning/ROADMAP.md` to extract a full roadmap
|
|
9
|
+
* with milestones, phases (with goals, deliverables, status), reads per-phase
|
|
10
|
+
* PLAN.md for task counts, TESTS.md for test counts, VERIFIED.md for verification
|
|
11
|
+
* status, and extracts recent git commits and project info.
|
|
12
|
+
*
|
|
13
|
+
* All filesystem and git operations are injected as dependencies so tests can
|
|
14
|
+
* run entirely against mock data with no real filesystem access.
|
|
15
|
+
*
|
|
16
|
+
* TDD: RED phase — these tests are written BEFORE the implementation.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
19
|
+
import { createProjectStatus } from './project-status.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Mock factories
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a mock `fs` dependency backed by an in-memory file map.
|
|
27
|
+
* Keys are absolute file paths, values are file contents (strings).
|
|
28
|
+
* @param {Record<string, string>} files - Map of path -> content
|
|
29
|
+
* @returns {{ existsSync: vi.fn, readFileSync: vi.fn, readdirSync: vi.fn }}
|
|
30
|
+
*/
|
|
31
|
+
function createMockFs(files = {}) {
|
|
32
|
+
return {
|
|
33
|
+
existsSync: vi.fn((p) => p in files),
|
|
34
|
+
readFileSync: vi.fn((p) => {
|
|
35
|
+
if (p in files) return files[p];
|
|
36
|
+
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
|
37
|
+
}),
|
|
38
|
+
readdirSync: vi.fn((p) => {
|
|
39
|
+
// Return filenames that are direct children of the directory path
|
|
40
|
+
return Object.keys(files)
|
|
41
|
+
.filter((f) => f.startsWith(p + '/') && !f.slice(p.length + 1).includes('/'))
|
|
42
|
+
.map((f) => f.slice(p.length + 1));
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a mock `execSync` that returns a configurable string for git log.
|
|
49
|
+
* @param {string} output - The stdout to return
|
|
50
|
+
* @returns {vi.fn}
|
|
51
|
+
*/
|
|
52
|
+
function createMockExecSync(output = '') {
|
|
53
|
+
return vi.fn(() => output);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Shared mock content
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const MOCK_ROADMAP = `# TLC Roadmap - v1.0
|
|
61
|
+
|
|
62
|
+
## Milestone: v1.0 - Team Collaboration Release
|
|
63
|
+
|
|
64
|
+
### Phase 1: Core Infrastructure [x]
|
|
65
|
+
|
|
66
|
+
**Goal:** Establish TLC as source of truth for planning.
|
|
67
|
+
|
|
68
|
+
**Deliverables:**
|
|
69
|
+
- [x] CLAUDE.md enforcement
|
|
70
|
+
- [x] Multi-user task claiming
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### Phase 2: Test Quality [x] ✓ COMPLETE
|
|
75
|
+
|
|
76
|
+
**Goal:** Improve test quality metrics.
|
|
77
|
+
|
|
78
|
+
**Deliverables:**
|
|
79
|
+
- [x] Test quality scoring
|
|
80
|
+
- [x] Auto-fix on failure
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### Phase 3: Dev Server [>]
|
|
85
|
+
|
|
86
|
+
**Goal:** Unified development environment.
|
|
87
|
+
|
|
88
|
+
**Deliverables:**
|
|
89
|
+
- [x] Auto-detect project type
|
|
90
|
+
- [ ] Docker-compose generation
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Milestone: v2.0 - Standalone Release
|
|
95
|
+
|
|
96
|
+
### Phase 4: LLM Router [ ]
|
|
97
|
+
|
|
98
|
+
**Goal:** Multi-model support.
|
|
99
|
+
|
|
100
|
+
**Deliverables:**
|
|
101
|
+
- [ ] Model routing
|
|
102
|
+
- [ ] Provider config
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const MOCK_PLAN = `# Phase 1: Core Infrastructure - Plan
|
|
106
|
+
|
|
107
|
+
## Tasks
|
|
108
|
+
|
|
109
|
+
### Task 1: CLAUDE.md [x]
|
|
110
|
+
|
|
111
|
+
### Task 2: Multi-user [x]
|
|
112
|
+
|
|
113
|
+
### Task 3: Bug tracking [ ]
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const MOCK_TESTS = `# Phase 1 Tests
|
|
117
|
+
|
|
118
|
+
## Test Files
|
|
119
|
+
|
|
120
|
+
| File | Tests | Status |
|
|
121
|
+
|------|-------|--------|
|
|
122
|
+
| lib/core.test.js | 10 | Passing |
|
|
123
|
+
| lib/user.test.js | 5 | Passing |
|
|
124
|
+
| **Total** | **15** | **All Passing** |
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
const MOCK_PACKAGE_JSON = JSON.stringify({
|
|
128
|
+
name: 'test-project',
|
|
129
|
+
version: '2.1.0',
|
|
130
|
+
description: 'A test project for TLC',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const MOCK_PROJECT_MD = `# Test Project
|
|
134
|
+
|
|
135
|
+
This is the project description paragraph that should be extracted.
|
|
136
|
+
|
|
137
|
+
## Architecture
|
|
138
|
+
|
|
139
|
+
Some architecture notes.
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const MOCK_GIT_LOG = `abc1234|feat: add roadmap parser|2026-02-09|Jurgen
|
|
143
|
+
def5678|fix: correct milestone grouping|2026-02-08|Alice
|
|
144
|
+
ghi9012|test: add project-status tests|2026-02-07|Bob`;
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Tests
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe('project-status', () => {
|
|
151
|
+
let mockFs;
|
|
152
|
+
let mockExecSync;
|
|
153
|
+
let projectStatus;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Builds a full file map for a project at the given path.
|
|
157
|
+
* Includes ROADMAP.md, a Phase 1 PLAN, TESTS, and VERIFIED file,
|
|
158
|
+
* plus package.json and PROJECT.md.
|
|
159
|
+
* @param {string} projectPath - Absolute path to the project root
|
|
160
|
+
* @returns {Record<string, string>} File map
|
|
161
|
+
*/
|
|
162
|
+
function buildFullFileMap(projectPath) {
|
|
163
|
+
return {
|
|
164
|
+
[`${projectPath}/package.json`]: MOCK_PACKAGE_JSON,
|
|
165
|
+
[`${projectPath}/PROJECT.md`]: MOCK_PROJECT_MD,
|
|
166
|
+
[`${projectPath}/.planning/ROADMAP.md`]: MOCK_ROADMAP,
|
|
167
|
+
[`${projectPath}/.planning/phases`]: '', // directory marker
|
|
168
|
+
[`${projectPath}/.planning/phases/1-PLAN.md`]: MOCK_PLAN,
|
|
169
|
+
[`${projectPath}/.planning/phases/1-TESTS.md`]: MOCK_TESTS,
|
|
170
|
+
[`${projectPath}/.planning/phases/1-VERIFIED.md`]: '# Verified\nPhase 1 verified.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
const files = buildFullFileMap('/project');
|
|
176
|
+
mockFs = createMockFs(files);
|
|
177
|
+
mockExecSync = createMockExecSync(MOCK_GIT_LOG);
|
|
178
|
+
projectStatus = createProjectStatus({ fs: mockFs, execSync: mockExecSync });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('parses full roadmap with milestones grouping phases', () => {
|
|
182
|
+
const result = projectStatus.getFullStatus('/project');
|
|
183
|
+
|
|
184
|
+
expect(result.milestones).toBeDefined();
|
|
185
|
+
expect(Array.isArray(result.milestones)).toBe(true);
|
|
186
|
+
expect(result.milestones).toHaveLength(2);
|
|
187
|
+
|
|
188
|
+
// First milestone has 3 phases
|
|
189
|
+
expect(result.milestones[0].name).toBe('v1.0 - Team Collaboration Release');
|
|
190
|
+
expect(result.milestones[0].phases).toHaveLength(3);
|
|
191
|
+
|
|
192
|
+
// Second milestone has 1 phase
|
|
193
|
+
expect(result.milestones[1].name).toBe('v2.0 - Standalone Release');
|
|
194
|
+
expect(result.milestones[1].phases).toHaveLength(1);
|
|
195
|
+
|
|
196
|
+
// Total phases
|
|
197
|
+
expect(result.totalPhases).toBe(4);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('extracts phase goals from ROADMAP.md', () => {
|
|
201
|
+
const result = projectStatus.getFullStatus('/project');
|
|
202
|
+
|
|
203
|
+
const phase1 = result.milestones[0].phases[0];
|
|
204
|
+
expect(phase1.goal).toBe('Establish TLC as source of truth for planning.');
|
|
205
|
+
|
|
206
|
+
const phase2 = result.milestones[0].phases[1];
|
|
207
|
+
expect(phase2.goal).toBe('Improve test quality metrics.');
|
|
208
|
+
|
|
209
|
+
const phase3 = result.milestones[0].phases[2];
|
|
210
|
+
expect(phase3.goal).toBe('Unified development environment.');
|
|
211
|
+
|
|
212
|
+
const phase4 = result.milestones[1].phases[0];
|
|
213
|
+
expect(phase4.goal).toBe('Multi-model support.');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('extracts phase deliverables as arrays', () => {
|
|
217
|
+
const result = projectStatus.getFullStatus('/project');
|
|
218
|
+
|
|
219
|
+
const phase1 = result.milestones[0].phases[0];
|
|
220
|
+
expect(phase1.deliverables).toHaveLength(2);
|
|
221
|
+
expect(phase1.deliverables[0]).toEqual({ text: 'CLAUDE.md enforcement', done: true });
|
|
222
|
+
expect(phase1.deliverables[1]).toEqual({ text: 'Multi-user task claiming', done: true });
|
|
223
|
+
|
|
224
|
+
const phase3 = result.milestones[0].phases[2];
|
|
225
|
+
expect(phase3.deliverables).toHaveLength(2);
|
|
226
|
+
expect(phase3.deliverables[0]).toEqual({ text: 'Auto-detect project type', done: true });
|
|
227
|
+
expect(phase3.deliverables[1]).toEqual({ text: 'Docker-compose generation', done: false });
|
|
228
|
+
|
|
229
|
+
const phase4 = result.milestones[1].phases[0];
|
|
230
|
+
expect(phase4.deliverables).toHaveLength(2);
|
|
231
|
+
expect(phase4.deliverables[0]).toEqual({ text: 'Model routing', done: false });
|
|
232
|
+
expect(phase4.deliverables[1]).toEqual({ text: 'Provider config', done: false });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('counts tasks from PLAN.md files', () => {
|
|
236
|
+
const result = projectStatus.getFullStatus('/project');
|
|
237
|
+
|
|
238
|
+
// Phase 1 has a PLAN.md with 3 tasks, 2 completed
|
|
239
|
+
const phase1 = result.milestones[0].phases[0];
|
|
240
|
+
expect(phase1.taskCount).toBe(3);
|
|
241
|
+
expect(phase1.completedTaskCount).toBe(2);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('parses test counts from TESTS.md', () => {
|
|
245
|
+
const result = projectStatus.getFullStatus('/project');
|
|
246
|
+
|
|
247
|
+
// Phase 1 has a TESTS.md with 2 test files, 15 total tests
|
|
248
|
+
const phase1 = result.milestones[0].phases[0];
|
|
249
|
+
expect(phase1.hasTests).toBe(true);
|
|
250
|
+
expect(phase1.testFileCount).toBe(2);
|
|
251
|
+
expect(phase1.testCount).toBe(15);
|
|
252
|
+
|
|
253
|
+
// Test summary should aggregate
|
|
254
|
+
expect(result.testSummary).toBeDefined();
|
|
255
|
+
expect(result.testSummary.totalFiles).toBe(2);
|
|
256
|
+
expect(result.testSummary.totalTests).toBe(15);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('detects verified phases from VERIFIED.md', () => {
|
|
260
|
+
const result = projectStatus.getFullStatus('/project');
|
|
261
|
+
|
|
262
|
+
// Phase 1 has a VERIFIED.md
|
|
263
|
+
const phase1 = result.milestones[0].phases[0];
|
|
264
|
+
expect(phase1.verified).toBe(true);
|
|
265
|
+
|
|
266
|
+
// Phase 2 does NOT have a VERIFIED.md
|
|
267
|
+
const phase2 = result.milestones[0].phases[1];
|
|
268
|
+
expect(phase2.verified).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('extracts recent commits via git log', () => {
|
|
272
|
+
const result = projectStatus.getFullStatus('/project');
|
|
273
|
+
|
|
274
|
+
expect(result.recentCommits).toBeDefined();
|
|
275
|
+
expect(Array.isArray(result.recentCommits)).toBe(true);
|
|
276
|
+
expect(result.recentCommits).toHaveLength(3);
|
|
277
|
+
|
|
278
|
+
expect(result.recentCommits[0]).toEqual({
|
|
279
|
+
hash: 'abc1234',
|
|
280
|
+
message: 'feat: add roadmap parser',
|
|
281
|
+
date: '2026-02-09',
|
|
282
|
+
author: 'Jurgen',
|
|
283
|
+
});
|
|
284
|
+
expect(result.recentCommits[1]).toEqual({
|
|
285
|
+
hash: 'def5678',
|
|
286
|
+
message: 'fix: correct milestone grouping',
|
|
287
|
+
date: '2026-02-08',
|
|
288
|
+
author: 'Alice',
|
|
289
|
+
});
|
|
290
|
+
expect(result.recentCommits[2]).toEqual({
|
|
291
|
+
hash: 'ghi9012',
|
|
292
|
+
message: 'test: add project-status tests',
|
|
293
|
+
date: '2026-02-07',
|
|
294
|
+
author: 'Bob',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Verify execSync was called with a git log command
|
|
298
|
+
expect(mockExecSync).toHaveBeenCalled();
|
|
299
|
+
const gitCall = mockExecSync.mock.calls[0][0];
|
|
300
|
+
expect(gitCall).toContain('git log');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('reads project info from package.json and PROJECT.md', () => {
|
|
304
|
+
const result = projectStatus.getFullStatus('/project');
|
|
305
|
+
|
|
306
|
+
expect(result.projectInfo).toBeDefined();
|
|
307
|
+
expect(result.projectInfo.name).toBe('test-project');
|
|
308
|
+
expect(result.projectInfo.version).toBe('2.1.0');
|
|
309
|
+
expect(result.projectInfo.description).toContain('project description paragraph');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('graceful fallback for missing .planning directory', () => {
|
|
313
|
+
// Build a project with no .planning directory at all
|
|
314
|
+
const files = {
|
|
315
|
+
'/empty-project/package.json': JSON.stringify({ name: 'empty', version: '0.1.0' }),
|
|
316
|
+
};
|
|
317
|
+
const fs = createMockFs(files);
|
|
318
|
+
const exec = createMockExecSync('');
|
|
319
|
+
const status = createProjectStatus({ fs, execSync: exec });
|
|
320
|
+
|
|
321
|
+
const result = status.getFullStatus('/empty-project');
|
|
322
|
+
|
|
323
|
+
expect(result).toBeDefined();
|
|
324
|
+
expect(result.milestones).toEqual([]);
|
|
325
|
+
expect(result.totalPhases).toBe(0);
|
|
326
|
+
expect(result.completedPhases).toBe(0);
|
|
327
|
+
expect(result.testSummary).toEqual(expect.objectContaining({
|
|
328
|
+
totalFiles: 0,
|
|
329
|
+
totalTests: 0,
|
|
330
|
+
}));
|
|
331
|
+
expect(result.projectInfo.name).toBe('empty');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('handles heading format with status suffixes', () => {
|
|
335
|
+
const result = projectStatus.getFullStatus('/project');
|
|
336
|
+
|
|
337
|
+
// Phase 1: [x] -> done
|
|
338
|
+
const phase1 = result.milestones[0].phases[0];
|
|
339
|
+
expect(phase1.number).toBe(1);
|
|
340
|
+
expect(phase1.name).toBe('Core Infrastructure');
|
|
341
|
+
expect(phase1.status).toBe('done');
|
|
342
|
+
|
|
343
|
+
// Phase 2: [x] ✓ COMPLETE -> done
|
|
344
|
+
const phase2 = result.milestones[0].phases[1];
|
|
345
|
+
expect(phase2.number).toBe(2);
|
|
346
|
+
expect(phase2.name).toBe('Test Quality');
|
|
347
|
+
expect(phase2.status).toBe('done');
|
|
348
|
+
|
|
349
|
+
// Phase 3: [>] -> in_progress
|
|
350
|
+
const phase3 = result.milestones[0].phases[2];
|
|
351
|
+
expect(phase3.number).toBe(3);
|
|
352
|
+
expect(phase3.name).toBe('Dev Server');
|
|
353
|
+
expect(phase3.status).toBe('in_progress');
|
|
354
|
+
|
|
355
|
+
// Phase 4: [ ] -> pending
|
|
356
|
+
const phase4 = result.milestones[1].phases[0];
|
|
357
|
+
expect(phase4.number).toBe(4);
|
|
358
|
+
expect(phase4.name).toBe('LLM Router');
|
|
359
|
+
expect(phase4.status).toBe('pending');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('returns zero phases for empty roadmap', () => {
|
|
363
|
+
const files = {
|
|
364
|
+
'/minimal/package.json': JSON.stringify({ name: 'minimal', version: '1.0.0' }),
|
|
365
|
+
'/minimal/.planning/ROADMAP.md': '# Roadmap\n\nNothing planned yet.\n',
|
|
366
|
+
'/minimal/.planning/phases': '',
|
|
367
|
+
};
|
|
368
|
+
const fs = createMockFs(files);
|
|
369
|
+
const exec = createMockExecSync('');
|
|
370
|
+
const status = createProjectStatus({ fs, execSync: exec });
|
|
371
|
+
|
|
372
|
+
const result = status.getFullStatus('/minimal');
|
|
373
|
+
|
|
374
|
+
expect(result.totalPhases).toBe(0);
|
|
375
|
+
expect(result.completedPhases).toBe(0);
|
|
376
|
+
expect(result.milestones).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('falls back to deliverable counts when no PLAN.md exists', () => {
|
|
380
|
+
// Phases 2, 3, 4 have no PLAN.md but have deliverables in ROADMAP.md
|
|
381
|
+
const result = projectStatus.getFullStatus('/project');
|
|
382
|
+
|
|
383
|
+
// Phase 2: 2 deliverables, both [x] → taskCount=2, completedTaskCount=2
|
|
384
|
+
const phase2 = result.milestones[0].phases[1];
|
|
385
|
+
expect(phase2.taskCount).toBe(2);
|
|
386
|
+
expect(phase2.completedTaskCount).toBe(2);
|
|
387
|
+
|
|
388
|
+
// Phase 3: 2 deliverables, 1 [x] and 1 [ ] → taskCount=2, completedTaskCount=1
|
|
389
|
+
const phase3 = result.milestones[0].phases[2];
|
|
390
|
+
expect(phase3.taskCount).toBe(2);
|
|
391
|
+
expect(phase3.completedTaskCount).toBe(1);
|
|
392
|
+
|
|
393
|
+
// Phase 4: 2 deliverables, both [ ] → taskCount=2, completedTaskCount=0
|
|
394
|
+
const phase4 = result.milestones[1].phases[0];
|
|
395
|
+
expect(phase4.taskCount).toBe(2);
|
|
396
|
+
expect(phase4.completedTaskCount).toBe(0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('parses checklist items without Deliverables header', () => {
|
|
400
|
+
// Kasha-style ROADMAP: - [x] items directly under phase, no **Deliverables:** header
|
|
401
|
+
const roadmap = `# Roadmap
|
|
402
|
+
|
|
403
|
+
## Milestone: v1.0
|
|
404
|
+
|
|
405
|
+
### Phase 1: Foundation [x]
|
|
406
|
+
- **Discussion:** assessment.md
|
|
407
|
+
- **Plan:** plan.md
|
|
408
|
+
- [x] 1.1 Update framework
|
|
409
|
+
- [x] 1.2 Standardize Node.js
|
|
410
|
+
- [x] 1.3 Standardize TypeScript
|
|
411
|
+
|
|
412
|
+
### Phase 2: Backend [>]
|
|
413
|
+
- [x] 2.1 Migration Batch 1
|
|
414
|
+
- [x] 2.2 Migration Batch 2
|
|
415
|
+
- [ ] 2.3 TypeORM standardization
|
|
416
|
+
- [ ] 2.4 Gateway migration
|
|
417
|
+
`;
|
|
418
|
+
|
|
419
|
+
const files = {
|
|
420
|
+
'/kasha/package.json': JSON.stringify({ name: 'kasha', version: '1.0.0' }),
|
|
421
|
+
'/kasha/.planning/ROADMAP.md': roadmap,
|
|
422
|
+
};
|
|
423
|
+
const fs = createMockFs(files);
|
|
424
|
+
const exec = createMockExecSync('');
|
|
425
|
+
const status = createProjectStatus({ fs, execSync: exec });
|
|
426
|
+
|
|
427
|
+
const result = status.getFullStatus('/kasha');
|
|
428
|
+
|
|
429
|
+
// Phase 1: 3 checklist items, all done
|
|
430
|
+
const phase1 = result.milestones[0].phases[0];
|
|
431
|
+
expect(phase1.deliverables).toHaveLength(3);
|
|
432
|
+
expect(phase1.taskCount).toBe(3);
|
|
433
|
+
expect(phase1.completedTaskCount).toBe(3);
|
|
434
|
+
|
|
435
|
+
// Phase 2: 4 checklist items, 2 done
|
|
436
|
+
const phase2 = result.milestones[0].phases[1];
|
|
437
|
+
expect(phase2.deliverables).toHaveLength(4);
|
|
438
|
+
expect(phase2.taskCount).toBe(4);
|
|
439
|
+
expect(phase2.completedTaskCount).toBe(2);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('prefers PLAN.md task counts over deliverable counts', () => {
|
|
443
|
+
// Phase 1 has both a PLAN.md (3 tasks, 2 done) and deliverables (2 items, 2 done)
|
|
444
|
+
// PLAN.md should take precedence
|
|
445
|
+
const result = projectStatus.getFullStatus('/project');
|
|
446
|
+
|
|
447
|
+
const phase1 = result.milestones[0].phases[0];
|
|
448
|
+
expect(phase1.taskCount).toBe(3);
|
|
449
|
+
expect(phase1.completedTaskCount).toBe(2);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('milestone boundary correctly separates phases', () => {
|
|
453
|
+
const result = projectStatus.getFullStatus('/project');
|
|
454
|
+
|
|
455
|
+
// v1.0 milestone should have phases 1, 2, 3
|
|
456
|
+
const v1Phases = result.milestones[0].phases;
|
|
457
|
+
expect(v1Phases.map((p) => p.number)).toEqual([1, 2, 3]);
|
|
458
|
+
|
|
459
|
+
// v2.0 milestone should have phase 4 only
|
|
460
|
+
const v2Phases = result.milestones[1].phases;
|
|
461
|
+
expect(v2Phases.map((p) => p.number)).toEqual([4]);
|
|
462
|
+
|
|
463
|
+
// Phases should not leak across milestone boundaries
|
|
464
|
+
const allPhaseNumbers = result.milestones.flatMap((m) => m.phases.map((p) => p.number));
|
|
465
|
+
expect(allPhaseNumbers).toEqual([1, 2, 3, 4]);
|
|
466
|
+
|
|
467
|
+
// completedPhases should count all [x] phases across milestones
|
|
468
|
+
expect(result.completedPhases).toBe(2);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects Registry - Track repos in a workspace
|
|
3
|
+
*
|
|
4
|
+
* Provides a registry for managing multiple projects within a workspace.
|
|
5
|
+
* Supports adding, removing, listing, and auto-detecting projects.
|
|
6
|
+
*
|
|
7
|
+
* @module projects-registry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const PROJECTS_FILE = 'projects.json';
|
|
15
|
+
|
|
16
|
+
/** Directories to ignore when scanning for repos */
|
|
17
|
+
const IGNORE_DIRS = ['node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'coverage'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate a git URL (SSH or HTTPS format)
|
|
21
|
+
* @param {string} url - The URL to validate
|
|
22
|
+
* @throws {Error} If the URL is not a valid git URL
|
|
23
|
+
*/
|
|
24
|
+
function validateGitUrl(url) {
|
|
25
|
+
// SSH format: git@host:user/repo.git
|
|
26
|
+
const sshPattern = /^git@[\w.-]+:[\w./-]+$/;
|
|
27
|
+
// HTTPS format: https://host/user/repo.git or https://host/user/repo
|
|
28
|
+
const httpsPattern = /^https:\/\/[\w.-]+\/[\w./-]+$/;
|
|
29
|
+
|
|
30
|
+
if (!sshPattern.test(url) && !httpsPattern.test(url)) {
|
|
31
|
+
throw new Error(`Invalid git URL: "${url}". Must be SSH (git@host:user/repo.git) or HTTPS (https://host/user/repo.git)`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure a path is relative (strip leading /)
|
|
37
|
+
* @param {string} localPath - The path to normalize
|
|
38
|
+
* @returns {string} Relative path
|
|
39
|
+
*/
|
|
40
|
+
function ensureRelativePath(localPath) {
|
|
41
|
+
return localPath.replace(/^\/+/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Try to extract git remote URL from a repo directory
|
|
46
|
+
* Uses execSync first, falls back to parsing .git/config
|
|
47
|
+
* @param {string} repoAbsPath - Absolute path to the repo
|
|
48
|
+
* @returns {string} Git remote URL or empty string
|
|
49
|
+
*/
|
|
50
|
+
function extractGitRemoteUrl(repoAbsPath) {
|
|
51
|
+
// Try execSync first
|
|
52
|
+
try {
|
|
53
|
+
const url = execSync('git remote get-url origin', {
|
|
54
|
+
cwd: repoAbsPath,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
|
+
}).trim();
|
|
58
|
+
if (url) return url;
|
|
59
|
+
} catch {
|
|
60
|
+
// execSync failed, try parsing .git/config
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fallback: parse .git/config
|
|
64
|
+
try {
|
|
65
|
+
const configPath = path.join(repoAbsPath, '.git', 'config');
|
|
66
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
67
|
+
const match = content.match(/\[remote "origin"\][^[]*url\s*=\s*(.+)/);
|
|
68
|
+
if (match) {
|
|
69
|
+
return match[1].trim();
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Can't read git config
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a directory has TLC configured
|
|
80
|
+
* @param {string} dirAbsPath - Absolute path to directory
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
function hasTlcConfig(dirAbsPath) {
|
|
84
|
+
try {
|
|
85
|
+
return fs.statSync(path.join(dirAbsPath, '.tlc.json')).isFile();
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a projects registry instance
|
|
93
|
+
* @returns {Object} Registry with load, save, addProject, removeProject, listProjects, detectFromFilesystem methods
|
|
94
|
+
*/
|
|
95
|
+
function createProjectsRegistry() {
|
|
96
|
+
/**
|
|
97
|
+
* Load projects.json from a workspace root
|
|
98
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
99
|
+
* @returns {Promise<{version: number, projects: Array}>} Registry data
|
|
100
|
+
*/
|
|
101
|
+
async function load(workspaceRoot) {
|
|
102
|
+
const filePath = path.join(workspaceRoot, PROJECTS_FILE);
|
|
103
|
+
try {
|
|
104
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
105
|
+
return JSON.parse(content);
|
|
106
|
+
} catch {
|
|
107
|
+
return { version: 1, projects: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Save registry data to projects.json atomically (write to temp, then rename)
|
|
113
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
114
|
+
* @param {Object} registryData - Registry data with version and projects
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
async function save(workspaceRoot, registryData) {
|
|
118
|
+
if (!registryData || typeof registryData.version !== 'number') {
|
|
119
|
+
throw new Error('Registry data must have a "version" field');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const filePath = path.join(workspaceRoot, PROJECTS_FILE);
|
|
123
|
+
const tempPath = filePath + '.tmp.' + Date.now();
|
|
124
|
+
|
|
125
|
+
const content = JSON.stringify(registryData, null, 2) + '\n';
|
|
126
|
+
fs.writeFileSync(tempPath, content, 'utf-8');
|
|
127
|
+
fs.renameSync(tempPath, filePath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Add a project to the registry
|
|
132
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
133
|
+
* @param {Object} project - Project details
|
|
134
|
+
* @param {string} project.name - Project name
|
|
135
|
+
* @param {string} project.gitUrl - Git remote URL (SSH or HTTPS)
|
|
136
|
+
* @param {string} project.localPath - Relative path within workspace
|
|
137
|
+
* @param {string} project.branch - Default branch name
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
async function addProject(workspaceRoot, { name, gitUrl, localPath, branch }) {
|
|
141
|
+
validateGitUrl(gitUrl);
|
|
142
|
+
|
|
143
|
+
const registryData = await load(workspaceRoot);
|
|
144
|
+
const existing = registryData.projects.find(p => p.name === name);
|
|
145
|
+
if (existing) {
|
|
146
|
+
throw new Error(`Project "${name}" already exists in registry`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
registryData.projects.push({
|
|
150
|
+
name,
|
|
151
|
+
gitUrl,
|
|
152
|
+
localPath: ensureRelativePath(localPath),
|
|
153
|
+
defaultBranch: branch || 'main',
|
|
154
|
+
hasTlc: false,
|
|
155
|
+
description: '',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await save(workspaceRoot, registryData);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove a project from the registry by name
|
|
163
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
164
|
+
* @param {string} name - Project name to remove
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
async function removeProject(workspaceRoot, name) {
|
|
168
|
+
const registryData = await load(workspaceRoot);
|
|
169
|
+
registryData.projects = registryData.projects.filter(p => p.name !== name);
|
|
170
|
+
await save(workspaceRoot, registryData);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* List all projects in the registry
|
|
175
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
176
|
+
* @returns {Promise<Array>} Array of project entries
|
|
177
|
+
*/
|
|
178
|
+
async function listProjects(workspaceRoot) {
|
|
179
|
+
const registryData = await load(workspaceRoot);
|
|
180
|
+
return registryData.projects;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Detect projects from filesystem by scanning for directories with .git/
|
|
185
|
+
* @param {string} workspaceRoot - Absolute path to workspace
|
|
186
|
+
* @returns {Promise<Array>} Array of detected project entries
|
|
187
|
+
*/
|
|
188
|
+
async function detectFromFilesystem(workspaceRoot) {
|
|
189
|
+
const detected = [];
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
|
|
193
|
+
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (!entry.isDirectory()) continue;
|
|
196
|
+
if (IGNORE_DIRS.includes(entry.name)) continue;
|
|
197
|
+
|
|
198
|
+
const dirAbsPath = path.join(workspaceRoot, entry.name);
|
|
199
|
+
const gitDir = path.join(dirAbsPath, '.git');
|
|
200
|
+
|
|
201
|
+
// Only include directories that have a .git/ subdirectory
|
|
202
|
+
try {
|
|
203
|
+
const stat = fs.statSync(gitDir);
|
|
204
|
+
if (!stat.isDirectory()) continue;
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const gitUrl = extractGitRemoteUrl(dirAbsPath);
|
|
210
|
+
|
|
211
|
+
detected.push({
|
|
212
|
+
name: entry.name,
|
|
213
|
+
gitUrl,
|
|
214
|
+
localPath: entry.name,
|
|
215
|
+
defaultBranch: 'main',
|
|
216
|
+
hasTlc: hasTlcConfig(dirAbsPath),
|
|
217
|
+
description: '',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Ignore scan errors
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return detected;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
load,
|
|
229
|
+
save,
|
|
230
|
+
addProject,
|
|
231
|
+
removeProject,
|
|
232
|
+
listProjects,
|
|
233
|
+
detectFromFilesystem,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { createProjectsRegistry };
|