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.
Files changed (76) 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 +13 -0
  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-writer.js +196 -0
  33. package/server/lib/plan-writer.test.js +298 -0
  34. package/server/lib/project-scanner.js +267 -0
  35. package/server/lib/project-scanner.test.js +389 -0
  36. package/server/lib/project-status.js +302 -0
  37. package/server/lib/project-status.test.js +470 -0
  38. package/server/lib/projects-registry.js +237 -0
  39. package/server/lib/projects-registry.test.js +275 -0
  40. package/server/lib/recall-command.js +207 -0
  41. package/server/lib/recall-command.test.js +306 -0
  42. package/server/lib/remember-command.js +96 -0
  43. package/server/lib/remember-command.test.js +265 -0
  44. package/server/lib/rich-capture.js +221 -0
  45. package/server/lib/rich-capture.test.js +312 -0
  46. package/server/lib/roadmap-api.js +200 -0
  47. package/server/lib/roadmap-api.test.js +318 -0
  48. package/server/lib/semantic-recall.js +242 -0
  49. package/server/lib/semantic-recall.test.js +446 -0
  50. package/server/lib/setup-generator.js +315 -0
  51. package/server/lib/setup-generator.test.js +303 -0
  52. package/server/lib/test-inventory.js +112 -0
  53. package/server/lib/test-inventory.test.js +360 -0
  54. package/server/lib/vector-indexer.js +246 -0
  55. package/server/lib/vector-indexer.test.js +459 -0
  56. package/server/lib/vector-store.js +260 -0
  57. package/server/lib/vector-store.test.js +706 -0
  58. package/server/lib/workspace-api.js +811 -0
  59. package/server/lib/workspace-api.test.js +743 -0
  60. package/server/lib/workspace-bootstrap.js +164 -0
  61. package/server/lib/workspace-bootstrap.test.js +503 -0
  62. package/server/lib/workspace-context.js +129 -0
  63. package/server/lib/workspace-context.test.js +214 -0
  64. package/server/lib/workspace-detector.js +162 -0
  65. package/server/lib/workspace-detector.test.js +193 -0
  66. package/server/lib/workspace-init.js +307 -0
  67. package/server/lib/workspace-init.test.js +244 -0
  68. package/server/lib/workspace-snapshot.js +236 -0
  69. package/server/lib/workspace-snapshot.test.js +444 -0
  70. package/server/lib/workspace-watcher.js +162 -0
  71. package/server/lib/workspace-watcher.test.js +257 -0
  72. package/server/package-lock.json +552 -0
  73. package/server/package.json +4 -0
  74. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  75. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  76. 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
+ });