tlc-claude-code 1.8.5 → 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 +13 -0
- 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-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,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Scanner - Recursively discovers TLC projects within configured root paths
|
|
3
|
+
*
|
|
4
|
+
* Scans directory trees looking for:
|
|
5
|
+
* - TLC projects (.tlc.json present)
|
|
6
|
+
* - Planning-only projects (.planning/ directory present)
|
|
7
|
+
* - Candidate projects (package.json + .git/ present, not yet initialized with TLC)
|
|
8
|
+
*
|
|
9
|
+
* Returns structured project metadata including phase info from ROADMAP.md.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Directories to skip during recursive scanning
|
|
17
|
+
*/
|
|
18
|
+
const IGNORED_DIRS = new Set([
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.git',
|
|
21
|
+
'dist',
|
|
22
|
+
'build',
|
|
23
|
+
'coverage',
|
|
24
|
+
'vendor',
|
|
25
|
+
'.next',
|
|
26
|
+
'.nuxt',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse phase information from a ROADMAP.md file
|
|
31
|
+
* @param {string} roadmapPath - Absolute path to ROADMAP.md
|
|
32
|
+
* @returns {{ phase: number|null, phaseName: string|null, totalPhases: number, completedPhases: number }}
|
|
33
|
+
*/
|
|
34
|
+
function parseRoadmap(roadmapPath) {
|
|
35
|
+
const result = {
|
|
36
|
+
phase: null,
|
|
37
|
+
phaseName: null,
|
|
38
|
+
totalPhases: 0,
|
|
39
|
+
completedPhases: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
45
|
+
} catch {
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Format 1: Heading format — ### Phase N: Name [x] / [ ] / [>]
|
|
50
|
+
const headingRegex = /###\s+Phase\s+(\d+)(?:\.\d+)?[:\s]+(.+?)\s*\[([x >])\]\s*$/gm;
|
|
51
|
+
let headingMatch;
|
|
52
|
+
let foundHeadings = false;
|
|
53
|
+
let firstIncomplete = null;
|
|
54
|
+
|
|
55
|
+
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
|
56
|
+
foundHeadings = true;
|
|
57
|
+
result.totalPhases++;
|
|
58
|
+
const phaseNum = parseInt(headingMatch[1], 10);
|
|
59
|
+
const phaseName = headingMatch[2].trim();
|
|
60
|
+
const marker = headingMatch[3];
|
|
61
|
+
|
|
62
|
+
if (marker === 'x') {
|
|
63
|
+
result.completedPhases++;
|
|
64
|
+
} else if (!firstIncomplete) {
|
|
65
|
+
firstIncomplete = { phase: phaseNum, phaseName };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (foundHeadings) {
|
|
70
|
+
if (firstIncomplete) {
|
|
71
|
+
result.phase = firstIncomplete.phase;
|
|
72
|
+
result.phaseName = firstIncomplete.phaseName;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Format 2: Table format — | N | [Name](link) | status |
|
|
78
|
+
const tableRegex = /\|\s*(\d+)\s*\|\s*\[([^\]]+)\][^|]*\|\s*(\w+)\s*\|/g;
|
|
79
|
+
let tableMatch;
|
|
80
|
+
|
|
81
|
+
while ((tableMatch = tableRegex.exec(content)) !== null) {
|
|
82
|
+
result.totalPhases++;
|
|
83
|
+
const phaseNum = parseInt(tableMatch[1], 10);
|
|
84
|
+
const phaseName = tableMatch[2].trim();
|
|
85
|
+
const status = tableMatch[3].trim().toLowerCase();
|
|
86
|
+
const completed = status === 'complete' || status === 'done' || status === 'verified';
|
|
87
|
+
|
|
88
|
+
if (completed) {
|
|
89
|
+
result.completedPhases++;
|
|
90
|
+
} else if (!firstIncomplete) {
|
|
91
|
+
firstIncomplete = { phase: phaseNum, phaseName };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (firstIncomplete) {
|
|
96
|
+
result.phase = firstIncomplete.phase;
|
|
97
|
+
result.phaseName = firstIncomplete.phaseName;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read project metadata from a project directory
|
|
105
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
106
|
+
* @returns {object} Project metadata
|
|
107
|
+
*/
|
|
108
|
+
function readProjectMetadata(projectDir) {
|
|
109
|
+
const hasTlc = fs.existsSync(path.join(projectDir, '.tlc.json'));
|
|
110
|
+
const hasPlanning = fs.existsSync(path.join(projectDir, '.planning'));
|
|
111
|
+
|
|
112
|
+
// Read name and version from package.json if present
|
|
113
|
+
let name = path.basename(projectDir);
|
|
114
|
+
let version = null;
|
|
115
|
+
|
|
116
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
117
|
+
if (fs.existsSync(pkgPath)) {
|
|
118
|
+
try {
|
|
119
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
120
|
+
if (pkg.name) {
|
|
121
|
+
name = pkg.name;
|
|
122
|
+
}
|
|
123
|
+
if (pkg.version) {
|
|
124
|
+
version = pkg.version;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore malformed package.json
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Parse phase info from ROADMAP.md if .planning exists
|
|
132
|
+
let phaseInfo = { phase: null, phaseName: null, totalPhases: 0, completedPhases: 0 };
|
|
133
|
+
if (hasPlanning) {
|
|
134
|
+
const roadmapPath = path.join(projectDir, '.planning', 'ROADMAP.md');
|
|
135
|
+
if (fs.existsSync(roadmapPath)) {
|
|
136
|
+
phaseInfo = parseRoadmap(roadmapPath);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
path: projectDir,
|
|
143
|
+
hasTlc,
|
|
144
|
+
hasPlanning,
|
|
145
|
+
version,
|
|
146
|
+
phase: phaseInfo.phase,
|
|
147
|
+
phaseName: phaseInfo.phaseName,
|
|
148
|
+
totalPhases: phaseInfo.totalPhases,
|
|
149
|
+
completedPhases: phaseInfo.completedPhases,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ProjectScanner - Recursively discovers TLC projects within configured root paths
|
|
155
|
+
*/
|
|
156
|
+
class ProjectScanner {
|
|
157
|
+
/**
|
|
158
|
+
* @param {object} [options]
|
|
159
|
+
* @param {number} [options.scanDepth=5] - Maximum recursion depth
|
|
160
|
+
* @param {number} [options.cacheTTL=60000] - Cache time-to-live in milliseconds
|
|
161
|
+
*/
|
|
162
|
+
constructor(options = {}) {
|
|
163
|
+
this.scanDepth = options.scanDepth || 5;
|
|
164
|
+
this.cacheTTL = options.cacheTTL || 60000;
|
|
165
|
+
this._cache = null;
|
|
166
|
+
this._cacheTime = 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Scan root directories for TLC projects
|
|
171
|
+
* @param {string[]} roots - Array of root directory paths to scan
|
|
172
|
+
* @param {object} [options]
|
|
173
|
+
* @param {boolean} [options.force=false] - Force re-scan bypassing cache
|
|
174
|
+
* @param {function} [options.onProgress] - Progress callback receiving discovered count
|
|
175
|
+
* @returns {object[]} Array of project metadata objects, sorted by name
|
|
176
|
+
*/
|
|
177
|
+
scan(roots, options = {}) {
|
|
178
|
+
const { force = false, onProgress } = options;
|
|
179
|
+
|
|
180
|
+
// Check cache
|
|
181
|
+
if (!force && this._cache !== null) {
|
|
182
|
+
const age = Date.now() - this._cacheTime;
|
|
183
|
+
if (age < this.cacheTTL) {
|
|
184
|
+
return this._cache;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const projectsByPath = new Map();
|
|
189
|
+
|
|
190
|
+
for (const root of roots) {
|
|
191
|
+
// Check that root exists
|
|
192
|
+
if (!fs.existsSync(root)) {
|
|
193
|
+
console.warn(`ProjectScanner: root path does not exist: ${root}`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this._scanDir(root, 0, projectsByPath, onProgress);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const projects = Array.from(projectsByPath.values());
|
|
201
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
202
|
+
|
|
203
|
+
// Update cache
|
|
204
|
+
this._cache = projects;
|
|
205
|
+
this._cacheTime = Date.now();
|
|
206
|
+
|
|
207
|
+
return projects;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Recursively scan a directory for projects
|
|
212
|
+
* @param {string} dir - Directory to scan
|
|
213
|
+
* @param {number} depth - Current recursion depth
|
|
214
|
+
* @param {Map} projectsByPath - Accumulated projects (keyed by absolute path for dedup)
|
|
215
|
+
* @param {function} [onProgress] - Progress callback
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
_scanDir(dir, depth, projectsByPath, onProgress) {
|
|
219
|
+
if (depth > this.scanDepth) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if this directory IS a project
|
|
224
|
+
const hasTlc = fs.existsSync(path.join(dir, '.tlc.json'));
|
|
225
|
+
const hasPlanning = fs.existsSync(path.join(dir, '.planning'));
|
|
226
|
+
const hasPackageJson = fs.existsSync(path.join(dir, 'package.json'));
|
|
227
|
+
const hasGit = fs.existsSync(path.join(dir, '.git'));
|
|
228
|
+
|
|
229
|
+
const isProject = hasTlc || hasPlanning || (hasPackageJson && hasGit);
|
|
230
|
+
|
|
231
|
+
if (isProject && !projectsByPath.has(dir)) {
|
|
232
|
+
const metadata = readProjectMetadata(dir);
|
|
233
|
+
projectsByPath.set(dir, metadata);
|
|
234
|
+
|
|
235
|
+
if (typeof onProgress === 'function') {
|
|
236
|
+
onProgress(projectsByPath.size);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Recurse into subdirectories
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
246
|
+
console.warn(`ProjectScanner: permission denied reading directory: ${dir}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (!entry.isDirectory()) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const childPath = path.join(dir, entry.name);
|
|
262
|
+
this._scanDir(childPath, depth + 1, projectsByPath, onProgress);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { ProjectScanner };
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const { ProjectScanner } = await import('./project-scanner.js');
|
|
7
|
+
|
|
8
|
+
describe('ProjectScanner', () => {
|
|
9
|
+
let tempDir;
|
|
10
|
+
let scanner;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'project-scanner-test-'));
|
|
14
|
+
scanner = new ProjectScanner();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Helper: create a minimal TLC project directory structure
|
|
23
|
+
*/
|
|
24
|
+
function createTlcProject(parentDir, name, options = {}) {
|
|
25
|
+
const projectDir = path.join(parentDir, name);
|
|
26
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
// .tlc.json
|
|
29
|
+
if (options.tlcJson !== false) {
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(projectDir, '.tlc.json'),
|
|
32
|
+
JSON.stringify(options.tlcJson || { name })
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// package.json
|
|
37
|
+
if (options.packageJson !== false) {
|
|
38
|
+
const pkg = options.packageJson || { name, version: '1.0.0' };
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(projectDir, 'package.json'),
|
|
41
|
+
JSON.stringify(pkg)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// .planning directory
|
|
46
|
+
if (options.planning !== false) {
|
|
47
|
+
fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// .git directory
|
|
51
|
+
if (options.git !== false) {
|
|
52
|
+
fs.mkdirSync(path.join(projectDir, '.git'), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ROADMAP.md
|
|
56
|
+
if (options.roadmap) {
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(projectDir, '.planning', 'ROADMAP.md'),
|
|
59
|
+
options.roadmap
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return projectDir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Test 1: Discovers project with .tlc.json
|
|
67
|
+
it('discovers project with .tlc.json', () => {
|
|
68
|
+
createTlcProject(tempDir, 'alpha-project');
|
|
69
|
+
|
|
70
|
+
const results = scanner.scan([tempDir]);
|
|
71
|
+
|
|
72
|
+
expect(results).toHaveLength(1);
|
|
73
|
+
expect(results[0].name).toBe('alpha-project');
|
|
74
|
+
expect(results[0].hasTlc).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Test 2: Discovers project with .planning/ directory (no .tlc.json)
|
|
78
|
+
it('discovers project with .planning/ directory (no .tlc.json)', () => {
|
|
79
|
+
createTlcProject(tempDir, 'planning-only', {
|
|
80
|
+
tlcJson: false,
|
|
81
|
+
packageJson: { name: 'planning-only', version: '2.0.0' },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const results = scanner.scan([tempDir]);
|
|
85
|
+
|
|
86
|
+
expect(results).toHaveLength(1);
|
|
87
|
+
expect(results[0].name).toBe('planning-only');
|
|
88
|
+
expect(results[0].hasTlc).toBe(false);
|
|
89
|
+
expect(results[0].hasPlanning).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Test 3: Discovers candidate project with package.json + .git/ (marked hasTlc: false)
|
|
93
|
+
it('discovers candidate project with package.json + .git/ (marked hasTlc: false)', () => {
|
|
94
|
+
createTlcProject(tempDir, 'candidate-project', {
|
|
95
|
+
tlcJson: false,
|
|
96
|
+
planning: false,
|
|
97
|
+
packageJson: { name: 'candidate-project', version: '0.1.0' },
|
|
98
|
+
git: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const results = scanner.scan([tempDir]);
|
|
102
|
+
|
|
103
|
+
expect(results).toHaveLength(1);
|
|
104
|
+
expect(results[0].name).toBe('candidate-project');
|
|
105
|
+
expect(results[0].hasTlc).toBe(false);
|
|
106
|
+
expect(results[0].hasPlanning).toBe(false);
|
|
107
|
+
expect(results[0].version).toBe('0.1.0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Test 4: Skips node_modules directories
|
|
111
|
+
it('skips node_modules directories', () => {
|
|
112
|
+
// Create a project inside node_modules — should be ignored
|
|
113
|
+
const nmDir = path.join(tempDir, 'node_modules');
|
|
114
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
115
|
+
createTlcProject(nmDir, 'hidden-project');
|
|
116
|
+
|
|
117
|
+
// Create a valid project at the top level
|
|
118
|
+
createTlcProject(tempDir, 'visible-project');
|
|
119
|
+
|
|
120
|
+
const results = scanner.scan([tempDir]);
|
|
121
|
+
|
|
122
|
+
expect(results).toHaveLength(1);
|
|
123
|
+
expect(results[0].name).toBe('visible-project');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Test 5: Skips all ignored directories (dist, build, coverage, etc.)
|
|
127
|
+
it('skips all ignored directories (dist, build, coverage, vendor, .next, .nuxt)', () => {
|
|
128
|
+
const ignoreDirs = ['dist', 'build', 'coverage', 'vendor', '.next', '.nuxt'];
|
|
129
|
+
|
|
130
|
+
for (const dir of ignoreDirs) {
|
|
131
|
+
const ignored = path.join(tempDir, dir);
|
|
132
|
+
fs.mkdirSync(ignored, { recursive: true });
|
|
133
|
+
createTlcProject(ignored, `project-in-${dir}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One valid project
|
|
137
|
+
createTlcProject(tempDir, 'valid-project');
|
|
138
|
+
|
|
139
|
+
const results = scanner.scan([tempDir]);
|
|
140
|
+
|
|
141
|
+
expect(results).toHaveLength(1);
|
|
142
|
+
expect(results[0].name).toBe('valid-project');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Test 6: Respects depth limit (default 5)
|
|
146
|
+
it('respects depth limit (default 5)', () => {
|
|
147
|
+
// Create a project nested 4 levels deep — within default depth of 5
|
|
148
|
+
let nested = tempDir;
|
|
149
|
+
for (let i = 0; i < 4; i++) {
|
|
150
|
+
nested = path.join(nested, `level${i}`);
|
|
151
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
createTlcProject(nested, 'deep-project');
|
|
154
|
+
|
|
155
|
+
const results = scanner.scan([tempDir]);
|
|
156
|
+
|
|
157
|
+
expect(results).toHaveLength(1);
|
|
158
|
+
expect(results[0].name).toBe('deep-project');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Test 7: Depth 1 only scans immediate children
|
|
162
|
+
it('depth 1 only scans immediate children', () => {
|
|
163
|
+
const shallowScanner = new ProjectScanner({ scanDepth: 1 });
|
|
164
|
+
|
|
165
|
+
// Immediate child — should be found
|
|
166
|
+
createTlcProject(tempDir, 'shallow-project');
|
|
167
|
+
|
|
168
|
+
// Nested one level deeper — should NOT be found
|
|
169
|
+
const subDir = path.join(tempDir, 'subdir');
|
|
170
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
171
|
+
createTlcProject(subDir, 'nested-project');
|
|
172
|
+
|
|
173
|
+
const results = shallowScanner.scan([tempDir]);
|
|
174
|
+
|
|
175
|
+
expect(results).toHaveLength(1);
|
|
176
|
+
expect(results[0].name).toBe('shallow-project');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Test 8: Returns empty array for empty root directory
|
|
180
|
+
it('returns empty array for empty root directory', () => {
|
|
181
|
+
const results = scanner.scan([tempDir]);
|
|
182
|
+
|
|
183
|
+
expect(results).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Test 9: Handles multiple root paths
|
|
187
|
+
it('handles multiple root paths', () => {
|
|
188
|
+
const root1 = fs.mkdtempSync(path.join(os.tmpdir(), 'root1-'));
|
|
189
|
+
const root2 = fs.mkdtempSync(path.join(os.tmpdir(), 'root2-'));
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
createTlcProject(root1, 'project-a');
|
|
193
|
+
createTlcProject(root2, 'project-b');
|
|
194
|
+
|
|
195
|
+
const results = scanner.scan([root1, root2]);
|
|
196
|
+
|
|
197
|
+
expect(results).toHaveLength(2);
|
|
198
|
+
const names = results.map(r => r.name);
|
|
199
|
+
expect(names).toContain('project-a');
|
|
200
|
+
expect(names).toContain('project-b');
|
|
201
|
+
} finally {
|
|
202
|
+
fs.rmSync(root1, { recursive: true, force: true });
|
|
203
|
+
fs.rmSync(root2, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Test 10: Deduplicates projects found in overlapping roots
|
|
208
|
+
it('deduplicates projects found in overlapping roots', () => {
|
|
209
|
+
createTlcProject(tempDir, 'unique-project');
|
|
210
|
+
|
|
211
|
+
// Pass the same root twice
|
|
212
|
+
const results = scanner.scan([tempDir, tempDir]);
|
|
213
|
+
|
|
214
|
+
expect(results).toHaveLength(1);
|
|
215
|
+
expect(results[0].name).toBe('unique-project');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Test 11: Caches results on repeated calls within TTL
|
|
219
|
+
it('caches results on repeated calls within TTL', () => {
|
|
220
|
+
createTlcProject(tempDir, 'cached-project');
|
|
221
|
+
|
|
222
|
+
const results1 = scanner.scan([tempDir]);
|
|
223
|
+
expect(results1).toHaveLength(1);
|
|
224
|
+
|
|
225
|
+
// Remove the project on disk
|
|
226
|
+
fs.rmSync(path.join(tempDir, 'cached-project'), { recursive: true, force: true });
|
|
227
|
+
|
|
228
|
+
// Second scan within TTL should return cached results
|
|
229
|
+
const results2 = scanner.scan([tempDir]);
|
|
230
|
+
expect(results2).toHaveLength(1);
|
|
231
|
+
expect(results2[0].name).toBe('cached-project');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Test 12: Force re-scan bypasses cache
|
|
235
|
+
it('force re-scan bypasses cache', () => {
|
|
236
|
+
createTlcProject(tempDir, 'cached-project');
|
|
237
|
+
|
|
238
|
+
const results1 = scanner.scan([tempDir]);
|
|
239
|
+
expect(results1).toHaveLength(1);
|
|
240
|
+
|
|
241
|
+
// Remove the project on disk
|
|
242
|
+
fs.rmSync(path.join(tempDir, 'cached-project'), { recursive: true, force: true });
|
|
243
|
+
|
|
244
|
+
// Force re-scan should see the project is gone
|
|
245
|
+
const results2 = scanner.scan([tempDir], { force: true });
|
|
246
|
+
expect(results2).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Test 13: Handles permission denied errors gracefully
|
|
250
|
+
it('handles permission denied errors gracefully', () => {
|
|
251
|
+
createTlcProject(tempDir, 'accessible-project');
|
|
252
|
+
|
|
253
|
+
// Create a directory that can't be read
|
|
254
|
+
const restrictedDir = path.join(tempDir, 'restricted');
|
|
255
|
+
fs.mkdirSync(restrictedDir, { recursive: true });
|
|
256
|
+
fs.chmodSync(restrictedDir, 0o000);
|
|
257
|
+
|
|
258
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const results = scanner.scan([tempDir]);
|
|
262
|
+
|
|
263
|
+
// Should still find the accessible project
|
|
264
|
+
expect(results).toHaveLength(1);
|
|
265
|
+
expect(results[0].name).toBe('accessible-project');
|
|
266
|
+
|
|
267
|
+
// Should have logged a warning about the restricted dir
|
|
268
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
269
|
+
} finally {
|
|
270
|
+
warnSpy.mockRestore();
|
|
271
|
+
// Restore permissions for cleanup
|
|
272
|
+
fs.chmodSync(restrictedDir, 0o755);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Test 14: Returns project metadata (name, version from package.json)
|
|
277
|
+
it('returns project metadata (name, version from package.json)', () => {
|
|
278
|
+
createTlcProject(tempDir, 'meta-project', {
|
|
279
|
+
packageJson: { name: 'meta-project', version: '3.2.1' },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const results = scanner.scan([tempDir]);
|
|
283
|
+
|
|
284
|
+
expect(results).toHaveLength(1);
|
|
285
|
+
expect(results[0].name).toBe('meta-project');
|
|
286
|
+
expect(results[0].path).toBe(path.join(tempDir, 'meta-project'));
|
|
287
|
+
expect(results[0].version).toBe('3.2.1');
|
|
288
|
+
expect(results[0].hasTlc).toBe(true);
|
|
289
|
+
expect(results[0].hasPlanning).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Test 15: Reads phase info from ROADMAP.md (heading format)
|
|
293
|
+
it('reads phase info from ROADMAP.md (heading format)', () => {
|
|
294
|
+
const roadmap = [
|
|
295
|
+
'# Roadmap',
|
|
296
|
+
'',
|
|
297
|
+
'### Phase 1: Core Infrastructure [x]',
|
|
298
|
+
'',
|
|
299
|
+
'### Phase 2: Test Quality [x]',
|
|
300
|
+
'',
|
|
301
|
+
'### Phase 3: Dev Server [>]',
|
|
302
|
+
'',
|
|
303
|
+
'### Phase 4: API Docs [ ]',
|
|
304
|
+
'',
|
|
305
|
+
'### Phase 5: CI/CD [ ]',
|
|
306
|
+
].join('\n');
|
|
307
|
+
|
|
308
|
+
createTlcProject(tempDir, 'roadmap-heading-project', { roadmap });
|
|
309
|
+
|
|
310
|
+
const results = scanner.scan([tempDir]);
|
|
311
|
+
|
|
312
|
+
expect(results).toHaveLength(1);
|
|
313
|
+
expect(results[0].phase).toBe(3);
|
|
314
|
+
expect(results[0].phaseName).toBe('Dev Server');
|
|
315
|
+
expect(results[0].totalPhases).toBe(5);
|
|
316
|
+
expect(results[0].completedPhases).toBe(2);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Test 16: Reads phase info from ROADMAP.md (table format)
|
|
320
|
+
it('reads phase info from ROADMAP.md (table format)', () => {
|
|
321
|
+
const roadmap = [
|
|
322
|
+
'# Roadmap',
|
|
323
|
+
'',
|
|
324
|
+
'| # | Phase | Status |',
|
|
325
|
+
'|---|-------|--------|',
|
|
326
|
+
'| 1 | [Auth](./phases/1-PLAN.md) | complete |',
|
|
327
|
+
'| 2 | [API](./phases/2-PLAN.md) | done |',
|
|
328
|
+
'| 3 | [UI](./phases/3-PLAN.md) | active |',
|
|
329
|
+
'| 4 | [Deploy](./phases/4-PLAN.md) | pending |',
|
|
330
|
+
].join('\n');
|
|
331
|
+
|
|
332
|
+
createTlcProject(tempDir, 'roadmap-table-project', { roadmap });
|
|
333
|
+
|
|
334
|
+
const results = scanner.scan([tempDir]);
|
|
335
|
+
|
|
336
|
+
expect(results).toHaveLength(1);
|
|
337
|
+
expect(results[0].phase).toBe(3);
|
|
338
|
+
expect(results[0].phaseName).toBe('UI');
|
|
339
|
+
expect(results[0].totalPhases).toBe(4);
|
|
340
|
+
expect(results[0].completedPhases).toBe(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Test 17: Projects sorted alphabetically by name
|
|
344
|
+
it('projects sorted alphabetically by name', () => {
|
|
345
|
+
createTlcProject(tempDir, 'zulu-project');
|
|
346
|
+
createTlcProject(tempDir, 'alpha-project');
|
|
347
|
+
createTlcProject(tempDir, 'mike-project');
|
|
348
|
+
|
|
349
|
+
const results = scanner.scan([tempDir]);
|
|
350
|
+
|
|
351
|
+
expect(results).toHaveLength(3);
|
|
352
|
+
expect(results[0].name).toBe('alpha-project');
|
|
353
|
+
expect(results[1].name).toBe('mike-project');
|
|
354
|
+
expect(results[2].name).toBe('zulu-project');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Test 18: Handles root path that doesn't exist (returns empty, logs warning)
|
|
358
|
+
it('handles root path that does not exist (returns empty, logs warning)', () => {
|
|
359
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const results = scanner.scan(['/tmp/nonexistent-root-path-xyz-123']);
|
|
363
|
+
|
|
364
|
+
expect(results).toEqual([]);
|
|
365
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
366
|
+
expect.stringContaining('/tmp/nonexistent-root-path-xyz-123')
|
|
367
|
+
);
|
|
368
|
+
} finally {
|
|
369
|
+
warnSpy.mockRestore();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Test 19: Scan progress callback reports discovered count
|
|
374
|
+
it('scan progress callback reports discovered count', () => {
|
|
375
|
+
createTlcProject(tempDir, 'project-one');
|
|
376
|
+
createTlcProject(tempDir, 'project-two');
|
|
377
|
+
|
|
378
|
+
const progressCounts = [];
|
|
379
|
+
const onProgress = (count) => progressCounts.push(count);
|
|
380
|
+
|
|
381
|
+
const results = scanner.scan([tempDir], { onProgress });
|
|
382
|
+
|
|
383
|
+
expect(results).toHaveLength(2);
|
|
384
|
+
// Progress callback should have been called at least once
|
|
385
|
+
expect(progressCounts.length).toBeGreaterThanOrEqual(1);
|
|
386
|
+
// The last reported count should match total found
|
|
387
|
+
expect(progressCounts[progressCounts.length - 1]).toBe(2);
|
|
388
|
+
});
|
|
389
|
+
});
|