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.
- package/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +98 -24
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +50 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/CODING-STANDARDS.md +217 -10
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/setup.sh +271 -271
- package/server/templates/CLAUDE.md +6 -0
- 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 };
|