tlc-claude-code 2.2.1 → 2.4.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 (49) hide show
  1. package/.claude/agents/builder.md +17 -0
  2. package/.claude/commands/tlc/audit.md +12 -0
  3. package/.claude/commands/tlc/autofix.md +31 -0
  4. package/.claude/commands/tlc/build.md +98 -24
  5. package/.claude/commands/tlc/coverage.md +31 -0
  6. package/.claude/commands/tlc/discuss.md +31 -0
  7. package/.claude/commands/tlc/docs.md +31 -0
  8. package/.claude/commands/tlc/edge-cases.md +31 -0
  9. package/.claude/commands/tlc/guard.md +9 -0
  10. package/.claude/commands/tlc/init.md +12 -1
  11. package/.claude/commands/tlc/plan.md +31 -0
  12. package/.claude/commands/tlc/quick.md +31 -0
  13. package/.claude/commands/tlc/review.md +50 -0
  14. package/.claude/hooks/tlc-session-init.sh +14 -3
  15. package/CODING-STANDARDS.md +217 -10
  16. package/bin/setup-autoupdate.js +316 -87
  17. package/bin/setup-autoupdate.test.js +454 -34
  18. package/package.json +1 -1
  19. package/scripts/project-docs.js +1 -1
  20. package/server/lib/careful-patterns.js +142 -0
  21. package/server/lib/careful-patterns.test.js +164 -0
  22. package/server/lib/cli-dispatcher.js +98 -0
  23. package/server/lib/cli-dispatcher.test.js +249 -0
  24. package/server/lib/command-router.js +171 -0
  25. package/server/lib/command-router.test.js +336 -0
  26. package/server/lib/field-report.js +92 -0
  27. package/server/lib/field-report.test.js +195 -0
  28. package/server/lib/orchestration/worktree-manager.js +133 -0
  29. package/server/lib/orchestration/worktree-manager.test.js +198 -0
  30. package/server/lib/overdrive-command.js +31 -9
  31. package/server/lib/overdrive-command.test.js +25 -26
  32. package/server/lib/prompt-packager.js +98 -0
  33. package/server/lib/prompt-packager.test.js +185 -0
  34. package/server/lib/review-fixer.js +107 -0
  35. package/server/lib/review-fixer.test.js +152 -0
  36. package/server/lib/routing-command.js +159 -0
  37. package/server/lib/routing-command.test.js +290 -0
  38. package/server/lib/scope-checker.js +127 -0
  39. package/server/lib/scope-checker.test.js +175 -0
  40. package/server/lib/skill-validator.js +165 -0
  41. package/server/lib/skill-validator.test.js +289 -0
  42. package/server/lib/standards/standards-injector.js +6 -0
  43. package/server/lib/task-router-config.js +142 -0
  44. package/server/lib/task-router-config.test.js +428 -0
  45. package/server/lib/test-selector.js +127 -0
  46. package/server/lib/test-selector.test.js +172 -0
  47. package/server/setup.sh +271 -271
  48. package/server/templates/CLAUDE.md +6 -0
  49. package/server/templates/CODING-STANDARDS.md +356 -10
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Task Router Config - Unified config loader for per-command model routing
3
+ *
4
+ * Merges personal -> project -> flag overrides into a single resolved routing table.
5
+ *
6
+ * Precedence (most specific wins):
7
+ * 1. --model flag (one-off override)
8
+ * 2. .tlc.json -> task_routing_override (project level)
9
+ * 3. ~/.tlc/config.json -> task_routing (personal level)
10
+ * 4. Shipped defaults (all commands -> claude, single strategy)
11
+ */
12
+
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Commands that support model routing.
17
+ * @type {string[]}
18
+ */
19
+ const ROUTABLE_COMMANDS = [
20
+ 'build', 'plan', 'review', 'test', 'coverage',
21
+ 'autofix', 'discuss', 'docs', 'edge-cases', 'quick',
22
+ ];
23
+
24
+ /**
25
+ * Default routing: every routable command maps to claude with single strategy.
26
+ * @type {Record<string, {models: string[], strategy: string}>}
27
+ */
28
+ const SHIPPED_DEFAULTS = {};
29
+ for (const cmd of ROUTABLE_COMMANDS) {
30
+ SHIPPED_DEFAULTS[cmd] = { models: ['claude'], strategy: 'single' };
31
+ }
32
+
33
+ /**
34
+ * Load personal config from ~/.tlc/config.json
35
+ * @param {object} options
36
+ * @param {string} options.homeDir - Home directory path
37
+ * @param {object} options.fs - Filesystem module (must have readFileSync)
38
+ * @returns {object|null} Parsed config or null on failure
39
+ */
40
+ function loadPersonalConfig({ homeDir, fs }) {
41
+ try {
42
+ const configPath = path.join(homeDir, '.tlc', 'config.json');
43
+ const content = fs.readFileSync(configPath, 'utf-8');
44
+ return JSON.parse(content);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Load project-level task routing override from .tlc.json
52
+ * @param {object} options
53
+ * @param {string} options.projectDir - Project directory path
54
+ * @param {object} options.fs - Filesystem module (must have readFileSync)
55
+ * @returns {object|null} The task_routing_override section or null
56
+ */
57
+ function loadProjectOverride({ projectDir, fs }) {
58
+ try {
59
+ const configPath = path.join(projectDir, '.tlc.json');
60
+ const content = fs.readFileSync(configPath, 'utf-8');
61
+ const data = JSON.parse(content);
62
+ return data.task_routing_override || null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolve the routing configuration for a given command.
70
+ *
71
+ * @param {object} options
72
+ * @param {string} options.command - The TLC command name (e.g. 'build', 'review')
73
+ * @param {string} [options.flagModel] - Model name from --model flag override
74
+ * @param {string} options.projectDir - Project directory path
75
+ * @param {string} options.homeDir - Home directory path
76
+ * @param {object} options.fs - Filesystem module (must have readFileSync)
77
+ * @returns {{ models: string[], strategy: string, source: string, providers?: object }}
78
+ */
79
+ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDir = require('os').homedir(), fs = require('fs') }) {
80
+ // Start with shipped defaults
81
+ const defaultEntry = SHIPPED_DEFAULTS[command] || { models: ['claude'], strategy: 'single' };
82
+ let models = [...defaultEntry.models];
83
+ let strategy = defaultEntry.strategy;
84
+ let source = 'shipped-defaults';
85
+ let providers;
86
+
87
+ // Layer 1: personal config (~/.tlc/config.json)
88
+ const personal = loadPersonalConfig({ homeDir, fs });
89
+ if (personal) {
90
+ // Capture model_providers if present
91
+ if (personal.model_providers) {
92
+ providers = personal.model_providers;
93
+ }
94
+
95
+ const personalRouting = personal.task_routing?.[command];
96
+ if (personalRouting) {
97
+ if (personalRouting.models) {
98
+ models = [...personalRouting.models];
99
+ }
100
+ if (personalRouting.strategy) {
101
+ strategy = personalRouting.strategy;
102
+ }
103
+ source = 'personal-config';
104
+ }
105
+ }
106
+
107
+ // Layer 2: project override (.tlc.json -> task_routing_override)
108
+ const projectOverride = loadProjectOverride({ projectDir, fs });
109
+ if (projectOverride) {
110
+ const overrideEntry = projectOverride[command];
111
+ if (overrideEntry) {
112
+ if (overrideEntry.models) {
113
+ models = [...overrideEntry.models];
114
+ }
115
+ if (overrideEntry.strategy) {
116
+ strategy = overrideEntry.strategy;
117
+ }
118
+ source = 'project-override';
119
+ }
120
+ }
121
+
122
+ // Layer 3: --model flag (highest priority)
123
+ if (flagModel) {
124
+ models = [flagModel];
125
+ strategy = 'single';
126
+ source = 'flag-override';
127
+ }
128
+
129
+ const result = { models, strategy, source };
130
+ if (providers) {
131
+ result.providers = providers;
132
+ }
133
+ return result;
134
+ }
135
+
136
+ module.exports = {
137
+ resolveRouting,
138
+ loadPersonalConfig,
139
+ loadProjectOverride,
140
+ SHIPPED_DEFAULTS,
141
+ ROUTABLE_COMMANDS,
142
+ };
@@ -0,0 +1,428 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ resolveRouting,
4
+ loadPersonalConfig,
5
+ loadProjectOverride,
6
+ SHIPPED_DEFAULTS,
7
+ ROUTABLE_COMMANDS,
8
+ } from './task-router-config.js';
9
+
10
+ /**
11
+ * Helper: build a mock fs with readFileSync that serves different
12
+ * content per path. Paths not in the map throw ENOENT.
13
+ */
14
+ function mockFs(fileMap = {}) {
15
+ return {
16
+ readFileSync(path, _encoding) {
17
+ if (fileMap[path] !== undefined) {
18
+ return fileMap[path];
19
+ }
20
+ const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
21
+ err.code = 'ENOENT';
22
+ throw err;
23
+ },
24
+ };
25
+ }
26
+
27
+ describe('task-router-config', () => {
28
+ // ── SHIPPED_DEFAULTS ──────────────────────────────────────────────
29
+ describe('SHIPPED_DEFAULTS', () => {
30
+ it('maps every routable command to claude/single', () => {
31
+ for (const cmd of ROUTABLE_COMMANDS) {
32
+ expect(SHIPPED_DEFAULTS[cmd]).toEqual({
33
+ models: ['claude'],
34
+ strategy: 'single',
35
+ });
36
+ }
37
+ });
38
+ });
39
+
40
+ // ── ROUTABLE_COMMANDS ─────────────────────────────────────────────
41
+ describe('ROUTABLE_COMMANDS', () => {
42
+ it('contains the expected commands', () => {
43
+ const expected = [
44
+ 'build', 'plan', 'review', 'test', 'coverage',
45
+ 'autofix', 'discuss', 'docs', 'edge-cases', 'quick',
46
+ ];
47
+ for (const cmd of expected) {
48
+ expect(ROUTABLE_COMMANDS).toContain(cmd);
49
+ }
50
+ });
51
+ });
52
+
53
+ // ── loadPersonalConfig ────────────────────────────────────────────
54
+ describe('loadPersonalConfig', () => {
55
+ it('returns parsed config when file exists', () => {
56
+ const personalConfig = {
57
+ task_routing: {
58
+ build: { models: ['codex'], strategy: 'single' },
59
+ },
60
+ model_providers: {
61
+ codex: { type: 'cli', command: 'codex' },
62
+ },
63
+ };
64
+ const fs = mockFs({
65
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
66
+ });
67
+
68
+ const result = loadPersonalConfig({ homeDir: '/home/user', fs });
69
+ expect(result).toEqual(personalConfig);
70
+ });
71
+
72
+ it('returns null when file does not exist', () => {
73
+ const fs = mockFs({});
74
+ const result = loadPersonalConfig({ homeDir: '/home/user', fs });
75
+ expect(result).toBeNull();
76
+ });
77
+
78
+ it('returns null for malformed JSON', () => {
79
+ const fs = mockFs({
80
+ '/home/user/.tlc/config.json': '{ not valid json',
81
+ });
82
+ const result = loadPersonalConfig({ homeDir: '/home/user', fs });
83
+ expect(result).toBeNull();
84
+ });
85
+ });
86
+
87
+ // ── loadProjectOverride ───────────────────────────────────────────
88
+ describe('loadProjectOverride', () => {
89
+ it('returns task_routing_override section when present', () => {
90
+ const tlcJson = {
91
+ task_routing_override: {
92
+ build: { models: ['local'], strategy: 'single' },
93
+ },
94
+ };
95
+ const fs = mockFs({
96
+ '/project/.tlc.json': JSON.stringify(tlcJson),
97
+ });
98
+
99
+ const result = loadProjectOverride({ projectDir: '/project', fs });
100
+ expect(result).toEqual(tlcJson.task_routing_override);
101
+ });
102
+
103
+ it('returns null when .tlc.json has no task_routing_override', () => {
104
+ const fs = mockFs({
105
+ '/project/.tlc.json': JSON.stringify({ router: {} }),
106
+ });
107
+
108
+ const result = loadProjectOverride({ projectDir: '/project', fs });
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it('returns null when .tlc.json does not exist', () => {
113
+ const fs = mockFs({});
114
+ const result = loadProjectOverride({ projectDir: '/project', fs });
115
+ expect(result).toBeNull();
116
+ });
117
+
118
+ it('returns null for malformed JSON', () => {
119
+ const fs = mockFs({
120
+ '/project/.tlc.json': 'broken!!!',
121
+ });
122
+ const result = loadProjectOverride({ projectDir: '/project', fs });
123
+ expect(result).toBeNull();
124
+ });
125
+ });
126
+
127
+ // ── resolveRouting ────────────────────────────────────────────────
128
+ describe('resolveRouting', () => {
129
+ it('returns shipped defaults when no config exists', () => {
130
+ const fs = mockFs({});
131
+ const result = resolveRouting({
132
+ command: 'build',
133
+ projectDir: '/project',
134
+ homeDir: '/home/user',
135
+ fs,
136
+ });
137
+
138
+ expect(result).toEqual({
139
+ models: ['claude'],
140
+ strategy: 'single',
141
+ source: 'shipped-defaults',
142
+ });
143
+ });
144
+
145
+ it('personal config overrides defaults', () => {
146
+ const personalConfig = {
147
+ task_routing: {
148
+ build: { models: ['codex'], strategy: 'single' },
149
+ },
150
+ };
151
+ const fs = mockFs({
152
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
153
+ });
154
+
155
+ const result = resolveRouting({
156
+ command: 'build',
157
+ projectDir: '/project',
158
+ homeDir: '/home/user',
159
+ fs,
160
+ });
161
+
162
+ expect(result).toEqual({
163
+ models: ['codex'],
164
+ strategy: 'single',
165
+ source: 'personal-config',
166
+ });
167
+ });
168
+
169
+ it('project override overrides personal config', () => {
170
+ const personalConfig = {
171
+ task_routing: {
172
+ build: { models: ['codex'], strategy: 'single' },
173
+ },
174
+ };
175
+ const tlcJson = {
176
+ task_routing_override: {
177
+ build: { models: ['local'], strategy: 'single' },
178
+ },
179
+ };
180
+ const fs = mockFs({
181
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
182
+ '/project/.tlc.json': JSON.stringify(tlcJson),
183
+ });
184
+
185
+ const result = resolveRouting({
186
+ command: 'build',
187
+ projectDir: '/project',
188
+ homeDir: '/home/user',
189
+ fs,
190
+ });
191
+
192
+ expect(result).toEqual({
193
+ models: ['local'],
194
+ strategy: 'single',
195
+ source: 'project-override',
196
+ });
197
+ });
198
+
199
+ it('flag override overrides everything', () => {
200
+ const personalConfig = {
201
+ task_routing: {
202
+ build: { models: ['codex'], strategy: 'single' },
203
+ },
204
+ };
205
+ const tlcJson = {
206
+ task_routing_override: {
207
+ build: { models: ['local'], strategy: 'single' },
208
+ },
209
+ };
210
+ const fs = mockFs({
211
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
212
+ '/project/.tlc.json': JSON.stringify(tlcJson),
213
+ });
214
+
215
+ const result = resolveRouting({
216
+ command: 'build',
217
+ flagModel: 'gemini',
218
+ projectDir: '/project',
219
+ homeDir: '/home/user',
220
+ fs,
221
+ });
222
+
223
+ expect(result).toEqual({
224
+ models: ['gemini'],
225
+ strategy: 'single',
226
+ source: 'flag-override',
227
+ });
228
+ });
229
+
230
+ it('unknown command returns defaults (claude, single)', () => {
231
+ const fs = mockFs({});
232
+ const result = resolveRouting({
233
+ command: 'nonexistent-command',
234
+ projectDir: '/project',
235
+ homeDir: '/home/user',
236
+ fs,
237
+ });
238
+
239
+ expect(result).toEqual({
240
+ models: ['claude'],
241
+ strategy: 'single',
242
+ source: 'shipped-defaults',
243
+ });
244
+ });
245
+
246
+ it('personal config for one command does not affect another', () => {
247
+ const personalConfig = {
248
+ task_routing: {
249
+ review: { models: ['claude', 'codex'], strategy: 'parallel' },
250
+ },
251
+ };
252
+ const fs = mockFs({
253
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
254
+ });
255
+
256
+ // 'review' should use personal config
257
+ const reviewResult = resolveRouting({
258
+ command: 'review',
259
+ projectDir: '/project',
260
+ homeDir: '/home/user',
261
+ fs,
262
+ });
263
+ expect(reviewResult).toEqual({
264
+ models: ['claude', 'codex'],
265
+ strategy: 'parallel',
266
+ source: 'personal-config',
267
+ });
268
+
269
+ // 'build' should still use defaults
270
+ const buildResult = resolveRouting({
271
+ command: 'build',
272
+ projectDir: '/project',
273
+ homeDir: '/home/user',
274
+ fs,
275
+ });
276
+ expect(buildResult).toEqual({
277
+ models: ['claude'],
278
+ strategy: 'single',
279
+ source: 'shipped-defaults',
280
+ });
281
+ });
282
+
283
+ it('partial personal config merges correctly (only strategy)', () => {
284
+ const personalConfig = {
285
+ task_routing: {
286
+ build: { strategy: 'parallel' },
287
+ },
288
+ };
289
+ const fs = mockFs({
290
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
291
+ });
292
+
293
+ const result = resolveRouting({
294
+ command: 'build',
295
+ projectDir: '/project',
296
+ homeDir: '/home/user',
297
+ fs,
298
+ });
299
+
300
+ // Should keep default models but use personal strategy
301
+ expect(result.strategy).toBe('parallel');
302
+ expect(result.models).toEqual(['claude']);
303
+ expect(result.source).toBe('personal-config');
304
+ });
305
+
306
+ it('partial personal config merges correctly (only models)', () => {
307
+ const personalConfig = {
308
+ task_routing: {
309
+ build: { models: ['codex'] },
310
+ },
311
+ };
312
+ const fs = mockFs({
313
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
314
+ });
315
+
316
+ const result = resolveRouting({
317
+ command: 'build',
318
+ projectDir: '/project',
319
+ homeDir: '/home/user',
320
+ fs,
321
+ });
322
+
323
+ expect(result.models).toEqual(['codex']);
324
+ expect(result.strategy).toBe('single');
325
+ expect(result.source).toBe('personal-config');
326
+ });
327
+
328
+ it('missing personal config file handled gracefully', () => {
329
+ const fs = mockFs({
330
+ '/project/.tlc.json': JSON.stringify({
331
+ task_routing_override: {
332
+ build: { models: ['local'], strategy: 'single' },
333
+ },
334
+ }),
335
+ });
336
+
337
+ const result = resolveRouting({
338
+ command: 'build',
339
+ projectDir: '/project',
340
+ homeDir: '/home/user',
341
+ fs,
342
+ });
343
+
344
+ expect(result).toEqual({
345
+ models: ['local'],
346
+ strategy: 'single',
347
+ source: 'project-override',
348
+ });
349
+ });
350
+
351
+ it('malformed personal JSON handled gracefully', () => {
352
+ const fs = mockFs({
353
+ '/home/user/.tlc/config.json': '{{{{not json!',
354
+ });
355
+
356
+ const result = resolveRouting({
357
+ command: 'build',
358
+ projectDir: '/project',
359
+ homeDir: '/home/user',
360
+ fs,
361
+ });
362
+
363
+ expect(result).toEqual({
364
+ models: ['claude'],
365
+ strategy: 'single',
366
+ source: 'shipped-defaults',
367
+ });
368
+ });
369
+
370
+ it('malformed project JSON handled gracefully', () => {
371
+ const fs = mockFs({
372
+ '/project/.tlc.json': 'not json either!',
373
+ });
374
+
375
+ const result = resolveRouting({
376
+ command: 'build',
377
+ projectDir: '/project',
378
+ homeDir: '/home/user',
379
+ fs,
380
+ });
381
+
382
+ expect(result).toEqual({
383
+ models: ['claude'],
384
+ strategy: 'single',
385
+ source: 'shipped-defaults',
386
+ });
387
+ });
388
+
389
+ it('model_providers loaded from personal config', () => {
390
+ const personalConfig = {
391
+ task_routing: {
392
+ build: { models: ['codex'], strategy: 'single' },
393
+ },
394
+ model_providers: {
395
+ claude: { type: 'inline' },
396
+ codex: { type: 'cli', command: 'codex' },
397
+ gemini: { type: 'cli', command: 'gemini' },
398
+ },
399
+ };
400
+ const fs = mockFs({
401
+ '/home/user/.tlc/config.json': JSON.stringify(personalConfig),
402
+ });
403
+
404
+ const result = resolveRouting({
405
+ command: 'build',
406
+ projectDir: '/project',
407
+ homeDir: '/home/user',
408
+ fs,
409
+ });
410
+
411
+ expect(result.models).toEqual(['codex']);
412
+ expect(result.providers).toEqual(personalConfig.model_providers);
413
+ });
414
+
415
+ it('returns no providers when personal config has none', () => {
416
+ const fs = mockFs({});
417
+
418
+ const result = resolveRouting({
419
+ command: 'build',
420
+ projectDir: '/project',
421
+ homeDir: '/home/user',
422
+ fs,
423
+ });
424
+
425
+ expect(result.providers).toBeUndefined();
426
+ });
427
+ });
428
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Diff-Based Test Selection
3
+ * Map test files to their source dependencies and select only affected tests
4
+ * when source files change.
5
+ */
6
+
7
+ /**
8
+ * Parse `// @depends path/to/file.js` comments from test file content.
9
+ * Supports multiple @depends lines and comma-separated paths on a single line.
10
+ *
11
+ * @param {string} testContent - The raw content of a test file
12
+ * @returns {string[]} Array of dependency paths extracted from @depends comments
13
+ */
14
+ function parseDependsComment(testContent) {
15
+ if (!testContent) return [];
16
+
17
+ const results = [];
18
+ const lines = testContent.split('\n');
19
+
20
+ for (const line of lines) {
21
+ const trimmed = line.trim();
22
+ // Only match lines that start with a comment marker
23
+ if (!trimmed.startsWith('//')) continue;
24
+
25
+ const match = trimmed.match(/^\/\/\s*@depends\s+(.+)$/);
26
+ if (!match) continue;
27
+
28
+ const pathsPart = match[1];
29
+ const paths = pathsPart.split(',').map((p) => p.trim()).filter(Boolean);
30
+ results.push(...paths);
31
+ }
32
+
33
+ return results;
34
+ }
35
+
36
+ /**
37
+ * Read each test file and extract @depends comments to build a mapping
38
+ * from test files to their source dependencies.
39
+ *
40
+ * @param {string[]} testFiles - Array of test file paths
41
+ * @param {object} options - Options object
42
+ * @param {function} options.readFile - Async function to read file contents (path) => string
43
+ * @returns {Promise<Map<string, string[]>>} Map from test file path to dependency paths
44
+ */
45
+ async function buildTestMap(testFiles, options = {}) {
46
+ if (!testFiles || testFiles.length === 0) return new Map();
47
+
48
+ const { readFile } = options;
49
+ if (!readFile) {
50
+ throw new Error('options.readFile is required');
51
+ }
52
+
53
+ const map = new Map();
54
+
55
+ for (const testFile of testFiles) {
56
+ try {
57
+ const content = await readFile(testFile);
58
+ const deps = parseDependsComment(content);
59
+ map.set(testFile, deps);
60
+ } catch {
61
+ // If we can't read the file, treat as no-depends (conservative)
62
+ map.set(testFile, []);
63
+ }
64
+ }
65
+
66
+ return map;
67
+ }
68
+
69
+ /**
70
+ * Given a list of changed source files and a test map, return the test files
71
+ * whose dependencies include any changed file. Tests with no @depends
72
+ * annotation are always included (conservative approach).
73
+ *
74
+ * @param {string[]} changedFiles - Array of changed source file paths
75
+ * @param {Map<string, string[]>} testMap - Map from test file to dependency paths
76
+ * @returns {string[]} Array of affected test file paths
77
+ */
78
+ function getAffectedTests(changedFiles, testMap) {
79
+ if (!testMap || testMap.size === 0) return [];
80
+ if (!changedFiles || !Array.isArray(changedFiles)) {
81
+ // Conservative fallback: run ALL tests when diff is unavailable
82
+ return [...testMap.keys()];
83
+ }
84
+
85
+ const changedSet = new Set(changedFiles);
86
+ const affected = [];
87
+
88
+ for (const [testFile, deps] of testMap) {
89
+ // Always include test files that were themselves changed in the diff
90
+ if (changedSet.has(testFile)) {
91
+ affected.push(testFile);
92
+ continue;
93
+ }
94
+
95
+ // No @depends means always include (conservative)
96
+ if (deps.length === 0) {
97
+ affected.push(testFile);
98
+ continue;
99
+ }
100
+
101
+ // Include if any dependency is in the changed set
102
+ const isAffected = deps.some((dep) => changedSet.has(dep));
103
+ if (isAffected) {
104
+ affected.push(testFile);
105
+ }
106
+ }
107
+
108
+ return affected;
109
+ }
110
+
111
+ /**
112
+ * Format a human-readable summary of test selection results.
113
+ *
114
+ * @param {number} affected - Number of affected (selected) tests
115
+ * @param {number} total - Total number of tests
116
+ * @returns {string} Human-readable selection summary
117
+ */
118
+ function formatSelection(affected, total) {
119
+ if (affected === total) {
120
+ return `Running all ${total} tests`;
121
+ }
122
+
123
+ const skipped = total - affected;
124
+ return `Running ${affected} of ${total} tests (${skipped} skipped — dependencies unchanged)`;
125
+ }
126
+
127
+ module.exports = { parseDependsComment, buildTestMap, getAffectedTests, formatSelection };