tlc-claude-code 1.8.5 → 2.1.0

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 (138) 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/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,541 @@
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
+
390
+ // =========================================================================
391
+ // Phase 79 — Task 1: Stop recursion at project boundaries
392
+ // =========================================================================
393
+
394
+ // Test 20: Does NOT recurse into subdirectories of a detected project
395
+ it('does not recurse into subdirectories of a detected project', () => {
396
+ // Create a TLC project with a nested sub-package that also looks like a project
397
+ const projectDir = createTlcProject(tempDir, 'monorepo-project');
398
+ const subPkgDir = path.join(projectDir, 'packages', 'sub-package');
399
+ fs.mkdirSync(subPkgDir, { recursive: true });
400
+ fs.writeFileSync(path.join(subPkgDir, 'package.json'), JSON.stringify({ name: 'sub-package' }));
401
+ fs.mkdirSync(path.join(subPkgDir, '.git'), { recursive: true });
402
+
403
+ const results = scanner.scan([tempDir]);
404
+
405
+ // Should only find the parent project, not the sub-package
406
+ expect(results).toHaveLength(1);
407
+ expect(results[0].name).toBe('monorepo-project');
408
+ });
409
+
410
+ // Test 21: TLC project's server/ subdirectory not listed separately
411
+ it('does not list subdirectories of a TLC project as separate projects', () => {
412
+ const projectDir = createTlcProject(tempDir, 'tlc-project');
413
+ // Create a server/ subdirectory with its own package.json + .git
414
+ const serverDir = path.join(projectDir, 'server');
415
+ fs.mkdirSync(serverDir, { recursive: true });
416
+ fs.writeFileSync(path.join(serverDir, 'package.json'), JSON.stringify({ name: 'tlc-server' }));
417
+ fs.mkdirSync(path.join(serverDir, '.git'), { recursive: true });
418
+
419
+ const results = scanner.scan([tempDir]);
420
+
421
+ expect(results).toHaveLength(1);
422
+ expect(results[0].name).toBe('tlc-project');
423
+ });
424
+
425
+ // Test 22: Top-level non-project directories are still traversed
426
+ it('still traverses non-project directories to find nested projects', () => {
427
+ // Create a plain directory (not a project) with a project nested inside
428
+ const groupDir = path.join(tempDir, 'my-workspace');
429
+ fs.mkdirSync(groupDir, { recursive: true });
430
+ // No .tlc.json, no .planning, no package.json+.git — just a folder
431
+ createTlcProject(groupDir, 'nested-real-project');
432
+
433
+ const results = scanner.scan([tempDir]);
434
+
435
+ expect(results).toHaveLength(1);
436
+ expect(results[0].name).toBe('nested-real-project');
437
+ });
438
+
439
+ // Test 23: Multiple projects at same level, none recurse into children
440
+ it('finds sibling projects but does not recurse into either', () => {
441
+ const projA = createTlcProject(tempDir, 'project-a');
442
+ const projB = createTlcProject(tempDir, 'project-b');
443
+
444
+ // Add nested sub-projects inside each
445
+ const nestedA = path.join(projA, 'nested');
446
+ fs.mkdirSync(nestedA, { recursive: true });
447
+ fs.writeFileSync(path.join(nestedA, '.tlc.json'), '{}');
448
+
449
+ const nestedB = path.join(projB, 'apps', 'frontend');
450
+ fs.mkdirSync(nestedB, { recursive: true });
451
+ fs.writeFileSync(path.join(nestedB, 'package.json'), JSON.stringify({ name: 'frontend' }));
452
+ fs.mkdirSync(path.join(nestedB, '.git'), { recursive: true });
453
+
454
+ const results = scanner.scan([tempDir]);
455
+
456
+ expect(results).toHaveLength(2);
457
+ const names = results.map(r => r.name);
458
+ expect(names).toContain('project-a');
459
+ expect(names).toContain('project-b');
460
+ });
461
+
462
+ // =========================================================================
463
+ // Phase 79 — Task 2: Monorepo sub-package metadata
464
+ // =========================================================================
465
+
466
+ // Test 24: Detects npm workspaces array format
467
+ it('detects npm workspaces and returns isMonorepo: true', () => {
468
+ createTlcProject(tempDir, 'npm-monorepo', {
469
+ packageJson: {
470
+ name: 'npm-monorepo',
471
+ version: '1.0.0',
472
+ workspaces: ['packages/*'],
473
+ },
474
+ });
475
+
476
+ // Create a matching sub-package directory
477
+ const pkgDir = path.join(tempDir, 'npm-monorepo', 'packages', 'core');
478
+ fs.mkdirSync(pkgDir, { recursive: true });
479
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/core' }));
480
+
481
+ const results = scanner.scan([tempDir]);
482
+
483
+ expect(results).toHaveLength(1);
484
+ expect(results[0].isMonorepo).toBe(true);
485
+ expect(results[0].workspaces).toBeInstanceOf(Array);
486
+ expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
487
+ });
488
+
489
+ // Test 25: Detects yarn workspaces object format
490
+ it('detects yarn workspaces object format', () => {
491
+ createTlcProject(tempDir, 'yarn-monorepo', {
492
+ packageJson: {
493
+ name: 'yarn-monorepo',
494
+ version: '1.0.0',
495
+ workspaces: { packages: ['packages/*'] },
496
+ },
497
+ });
498
+
499
+ const pkgDir = path.join(tempDir, 'yarn-monorepo', 'packages', 'utils');
500
+ fs.mkdirSync(pkgDir, { recursive: true });
501
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/utils' }));
502
+
503
+ const results = scanner.scan([tempDir]);
504
+
505
+ expect(results).toHaveLength(1);
506
+ expect(results[0].isMonorepo).toBe(true);
507
+ expect(results[0].workspaces).toBeInstanceOf(Array);
508
+ expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
509
+ });
510
+
511
+ // Test 26: Non-monorepo returns isMonorepo: false and empty workspaces
512
+ it('returns isMonorepo false and empty workspaces for regular project', () => {
513
+ createTlcProject(tempDir, 'regular-project', {
514
+ packageJson: { name: 'regular-project', version: '1.0.0' },
515
+ });
516
+
517
+ const results = scanner.scan([tempDir]);
518
+
519
+ expect(results).toHaveLength(1);
520
+ expect(results[0].isMonorepo).toBe(false);
521
+ expect(results[0].workspaces).toEqual([]);
522
+ });
523
+
524
+ // Test 27: Monorepo with no matching workspace directories
525
+ it('returns empty workspaces when glob pattern matches nothing', () => {
526
+ createTlcProject(tempDir, 'empty-mono', {
527
+ packageJson: {
528
+ name: 'empty-mono',
529
+ version: '1.0.0',
530
+ workspaces: ['packages/*'],
531
+ },
532
+ });
533
+ // Don't create the packages/ directory at all
534
+
535
+ const results = scanner.scan([tempDir]);
536
+
537
+ expect(results).toHaveLength(1);
538
+ expect(results[0].isMonorepo).toBe(true);
539
+ expect(results[0].workspaces).toEqual([]);
540
+ });
541
+ });