dual-brain 0.2.4 → 0.2.6
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/bin/dual-brain.mjs +366 -43
- package/package.json +10 -2
- package/src/awareness.mjs +71 -6
- package/src/checkpoint.mjs +109 -0
- package/src/ci-triage.mjs +191 -0
- package/src/continuity.mjs +291 -0
- package/src/detect.mjs +38 -0
- package/src/dispatch.mjs +73 -7
- package/src/doctor.mjs +6 -6
- package/src/health.mjs +37 -0
- package/src/pipeline.mjs +60 -3
- package/src/pr-agent.mjs +214 -0
- package/src/profile.mjs +39 -124
- package/src/replit.mjs +1 -1
- package/src/repo.mjs +153 -0
package/src/profile.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* getHeadModel(profile) → suggested head model string
|
|
16
16
|
* detectCapabilities(cwd) → what we can actually verify
|
|
17
17
|
* getOnboardingMessage(caps, ws) → honest 2-3 line status message
|
|
18
|
-
*
|
|
18
|
+
* detectCapabilities(cwd) → available providers (subscription-based only)
|
|
19
19
|
*
|
|
20
20
|
* CLI:
|
|
21
21
|
* node src/profile.mjs # show current profile
|
|
@@ -136,7 +136,7 @@ function detectEnvironment() {
|
|
|
136
136
|
* @param {string} [cwd]
|
|
137
137
|
* @returns {Promise<{
|
|
138
138
|
* claude: { available: boolean, source: string|null },
|
|
139
|
-
* openai: { available: boolean, source: string|null
|
|
139
|
+
* openai: { available: boolean, source: string|null },
|
|
140
140
|
* codex: { available: boolean, source: string|null },
|
|
141
141
|
* replitTools: { available: boolean, checkpoints: boolean },
|
|
142
142
|
* }>}
|
|
@@ -144,18 +144,15 @@ function detectEnvironment() {
|
|
|
144
144
|
async function detectCapabilities(cwd) {
|
|
145
145
|
const root = cwd || process.cwd();
|
|
146
146
|
|
|
147
|
-
// --- Claude: running inside Claude Code
|
|
147
|
+
// --- Claude: running inside Claude Code session or CLI installed ---
|
|
148
148
|
let claudeAvailable = false;
|
|
149
149
|
let claudeSource = null;
|
|
150
150
|
|
|
151
151
|
if (process.env.CLAUDE_CODE) {
|
|
152
152
|
claudeAvailable = true;
|
|
153
153
|
claudeSource = 'claude-code';
|
|
154
|
-
} else if (process.env.ANTHROPIC_API_KEY?.length > 0) {
|
|
155
|
-
claudeAvailable = true;
|
|
156
|
-
claudeSource = 'api-key';
|
|
157
154
|
} else {
|
|
158
|
-
// Check for ~/.claude directory (Claude Code installation)
|
|
155
|
+
// Check for ~/.claude directory (Claude Code installation) or Replit Claude
|
|
159
156
|
const claudeDir = join(homedir(), '.claude');
|
|
160
157
|
const replitClaudeDir = join(root, '.replit-tools', '.claude-persistent');
|
|
161
158
|
if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
|
|
@@ -164,9 +161,6 @@ async function detectCapabilities(cwd) {
|
|
|
164
161
|
}
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
// --- OpenAI: check for OPENAI_API_KEY presence (metered billing) ---
|
|
168
|
-
const openaiAvailable = !!process.env.OPENAI_API_KEY?.length;
|
|
169
|
-
|
|
170
164
|
// --- Codex: check if 'codex' is in PATH ---
|
|
171
165
|
let codexAvailable = false;
|
|
172
166
|
let codexSource = null;
|
|
@@ -200,9 +194,8 @@ async function detectCapabilities(cwd) {
|
|
|
200
194
|
source: claudeSource,
|
|
201
195
|
},
|
|
202
196
|
openai: {
|
|
203
|
-
available:
|
|
204
|
-
source:
|
|
205
|
-
metered: openaiAvailable && !codexAvailable,
|
|
197
|
+
available: codexAvailable,
|
|
198
|
+
source: codexAvailable ? 'codex-cli' : null,
|
|
206
199
|
},
|
|
207
200
|
codex: {
|
|
208
201
|
available: codexAvailable,
|
|
@@ -215,18 +208,6 @@ async function detectCapabilities(cwd) {
|
|
|
215
208
|
};
|
|
216
209
|
}
|
|
217
210
|
|
|
218
|
-
/**
|
|
219
|
-
* Return true if any metered API key is detected.
|
|
220
|
-
* When true, the system defaults to conservative API usage and should
|
|
221
|
-
* confirm before expensive operations.
|
|
222
|
-
*
|
|
223
|
-
* @param {ReturnType<typeof detectCapabilities> extends Promise<infer T> ? T : never} capabilities
|
|
224
|
-
* @returns {boolean}
|
|
225
|
-
*/
|
|
226
|
-
function needsApiGuardrail(capabilities) {
|
|
227
|
-
return !!(capabilities?.openai?.metered);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
211
|
/**
|
|
231
212
|
* Generate an honest 2-3 line onboarding/status message based on
|
|
232
213
|
* what we can actually verify.
|
|
@@ -237,9 +218,8 @@ function needsApiGuardrail(capabilities) {
|
|
|
237
218
|
*/
|
|
238
219
|
function getOnboardingMessage(capabilities, workStyle = 'balanced') {
|
|
239
220
|
const found = [];
|
|
240
|
-
if (capabilities?.claude?.available) found.push('Claude
|
|
241
|
-
if (capabilities?.
|
|
242
|
-
if (capabilities?.codex?.available && !capabilities?.openai?.available) found.push('Codex CLI');
|
|
221
|
+
if (capabilities?.claude?.available) found.push('Claude · subscription');
|
|
222
|
+
if (capabilities?.codex?.available) found.push('OpenAI · Codex subscription');
|
|
243
223
|
|
|
244
224
|
const styleLabels = {
|
|
245
225
|
'balanced': 'Balanced — smart routing, reviews on important changes',
|
|
@@ -258,16 +238,11 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
|
|
|
258
238
|
lines.push(`Found: ${found.join(', ')}`);
|
|
259
239
|
lines.push(` Mode: ${modeLabel}`);
|
|
260
240
|
|
|
261
|
-
// Tip: suggest
|
|
262
|
-
if (capabilities?.claude?.available && !capabilities?.
|
|
241
|
+
// Tip: suggest Codex if only Claude is available
|
|
242
|
+
if (capabilities?.claude?.available && !capabilities?.codex?.available) {
|
|
263
243
|
lines.push(' Tip: Run codex login for dual-brain collaboration');
|
|
264
244
|
}
|
|
265
245
|
|
|
266
|
-
// Warn about metered billing
|
|
267
|
-
if (capabilities?.openai?.metered) {
|
|
268
|
-
lines.push(' Note: OpenAI API key detected — usage is metered, guardrails enabled');
|
|
269
|
-
}
|
|
270
|
-
|
|
271
246
|
return lines.join('\n');
|
|
272
247
|
}
|
|
273
248
|
|
|
@@ -393,9 +368,8 @@ async function runOnboarding(opts = {}) {
|
|
|
393
368
|
|
|
394
369
|
// Show what we found honestly
|
|
395
370
|
const foundProviders = [];
|
|
396
|
-
if (capabilities.claude.available) foundProviders.push('Claude
|
|
397
|
-
if (capabilities.
|
|
398
|
-
if (capabilities.codex.available && !capabilities.openai.available) foundProviders.push('Codex CLI');
|
|
371
|
+
if (capabilities.claude.available) foundProviders.push('Claude · subscription');
|
|
372
|
+
if (capabilities.codex.available) foundProviders.push('OpenAI · Codex subscription');
|
|
399
373
|
|
|
400
374
|
if (foundProviders.length > 0) {
|
|
401
375
|
process.stdout.write(`Detected: ${foundProviders.join(', ')}\n\n`);
|
|
@@ -405,15 +379,14 @@ async function runOnboarding(opts = {}) {
|
|
|
405
379
|
|
|
406
380
|
// Enable providers based on what's available
|
|
407
381
|
profile.providers.claude.enabled = capabilities.claude.available;
|
|
408
|
-
profile.providers.openai.enabled = capabilities.
|
|
409
|
-
profile.apiGuardrail = needsApiGuardrail(capabilities);
|
|
382
|
+
profile.providers.openai.enabled = capabilities.codex.available;
|
|
410
383
|
|
|
411
384
|
// If detection missed something, ask
|
|
412
|
-
if (!capabilities.claude.available && !capabilities.
|
|
413
|
-
const q1 = (await ask('Which AI providers do you have access to?\n (1) Claude
|
|
385
|
+
if (!capabilities.claude.available && !capabilities.codex.available) {
|
|
386
|
+
const q1 = (await ask('Which AI providers do you have access to?\n (1) Claude only (2) OpenAI Codex only (3) Both (4) Neither\n> ')).trim();
|
|
414
387
|
if (q1 === '1') { profile.providers.claude.enabled = true; }
|
|
415
|
-
else if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true;
|
|
416
|
-
else if (q1 === '3') { profile.providers.claude.enabled = true; profile.providers.openai.enabled = true;
|
|
388
|
+
else if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; }
|
|
389
|
+
else if (q1 === '3') { profile.providers.claude.enabled = true; profile.providers.openai.enabled = true; }
|
|
417
390
|
}
|
|
418
391
|
|
|
419
392
|
const q3 = (await ask('\nDefault work style?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
|
|
@@ -546,19 +519,16 @@ async function autoSetup(cwd) {
|
|
|
546
519
|
result.actions.push(`Claude: available (${capabilities.claude.source})`);
|
|
547
520
|
} else {
|
|
548
521
|
profile.providers.claude.enabled = false;
|
|
549
|
-
result.warnings.push('Claude not detected —
|
|
522
|
+
result.warnings.push('Claude not detected — run: claude login');
|
|
550
523
|
}
|
|
551
524
|
|
|
552
525
|
// OpenAI / Codex
|
|
553
|
-
if (capabilities.
|
|
526
|
+
if (capabilities.codex.available) {
|
|
554
527
|
profile.providers.openai.enabled = true;
|
|
555
|
-
result.actions.push('
|
|
556
|
-
} else if (capabilities.codex.available) {
|
|
557
|
-
profile.providers.openai.enabled = true;
|
|
558
|
-
result.actions.push('Codex CLI: available');
|
|
528
|
+
result.actions.push('Codex CLI: available (subscription)');
|
|
559
529
|
} else {
|
|
560
530
|
profile.providers.openai.enabled = false;
|
|
561
|
-
result.warnings.push('OpenAI not detected —
|
|
531
|
+
result.warnings.push('OpenAI not detected — run: codex login');
|
|
562
532
|
}
|
|
563
533
|
|
|
564
534
|
// Mode
|
|
@@ -568,7 +538,6 @@ async function autoSetup(cwd) {
|
|
|
568
538
|
: 'solo-openai';
|
|
569
539
|
profile.bias = 'balanced';
|
|
570
540
|
profile.workStyle = 'balanced';
|
|
571
|
-
profile.apiGuardrail = needsApiGuardrail(capabilities);
|
|
572
541
|
profile.capabilities = capabilities;
|
|
573
542
|
profile.detectedAt = new Date().toISOString();
|
|
574
543
|
|
|
@@ -909,17 +878,6 @@ export async function checkCredentialHealth(cred, cwd = process.cwd()) {
|
|
|
909
878
|
} catch {
|
|
910
879
|
health = 'healthy'; // cli works, auth check unavailable
|
|
911
880
|
}
|
|
912
|
-
} else if (cred.auth_type === 'api_key') {
|
|
913
|
-
if (cred.source === 'replit_secret') {
|
|
914
|
-
try {
|
|
915
|
-
const { hasSecret } = await import('./replit.mjs');
|
|
916
|
-
health = hasSecret(cred.env_var || cred.id.toUpperCase().replace(/-/g, '_')) ? 'healthy' : 'unhealthy';
|
|
917
|
-
} catch { health = 'unknown'; }
|
|
918
|
-
} else {
|
|
919
|
-
// env source
|
|
920
|
-
const varName = cred.env_var || cred.id.toUpperCase().replace(/-/g, '_');
|
|
921
|
-
health = (process.env[varName] && process.env[varName].length > 0) ? 'healthy' : 'unhealthy';
|
|
922
|
-
}
|
|
923
881
|
}
|
|
924
882
|
} catch { health = 'unknown'; }
|
|
925
883
|
return { ...cred, health, last_checked_at: new Date().toISOString() };
|
|
@@ -949,64 +907,24 @@ export async function detectCredentials(cwd = process.cwd()) {
|
|
|
949
907
|
});
|
|
950
908
|
}
|
|
951
909
|
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
auth_type: 'api_key',
|
|
958
|
-
source: 'env',
|
|
959
|
-
env_var: 'ANTHROPIC_API_KEY',
|
|
960
|
-
owner: 'user',
|
|
961
|
-
scope: 'local',
|
|
962
|
-
plan_hint: null,
|
|
963
|
-
enabled: true,
|
|
964
|
-
health: 'healthy',
|
|
965
|
-
last_checked_at: new Date().toISOString(),
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// OPENAI_API_KEY (env)
|
|
970
|
-
if (process.env.OPENAI_API_KEY) {
|
|
910
|
+
// Codex CLI (subscription-based OpenAI access)
|
|
911
|
+
try {
|
|
912
|
+
execSync('which codex', { stdio: 'pipe', timeout: 2000 });
|
|
913
|
+
let codexHealth = 'unknown';
|
|
914
|
+
try { execSync('codex --version', { stdio: 'pipe', timeout: 3000 }); codexHealth = 'healthy'; } catch { codexHealth = 'degraded'; }
|
|
971
915
|
found.push({
|
|
972
|
-
id: 'openai-
|
|
916
|
+
id: 'openai-codex-cli',
|
|
973
917
|
provider: 'openai',
|
|
974
|
-
auth_type: '
|
|
975
|
-
source: '
|
|
976
|
-
env_var: 'OPENAI_API_KEY',
|
|
918
|
+
auth_type: 'cli_oauth',
|
|
919
|
+
source: 'local_cli',
|
|
977
920
|
owner: 'user',
|
|
978
921
|
scope: 'local',
|
|
979
922
|
plan_hint: null,
|
|
980
923
|
enabled: true,
|
|
981
|
-
health:
|
|
924
|
+
health: codexHealth,
|
|
982
925
|
last_checked_at: new Date().toISOString(),
|
|
983
926
|
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Replit secrets
|
|
987
|
-
try {
|
|
988
|
-
const { hasSecret } = await import('./replit.mjs');
|
|
989
|
-
const secretsToCheck = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'];
|
|
990
|
-
for (const name of secretsToCheck) {
|
|
991
|
-
if (!process.env[name] && hasSecret(name)) {
|
|
992
|
-
const provider = name.startsWith('OPENAI') ? 'openai' : 'claude';
|
|
993
|
-
const id = name.toLowerCase().replace(/_/g, '-') + '-replit';
|
|
994
|
-
found.push({
|
|
995
|
-
id,
|
|
996
|
-
provider,
|
|
997
|
-
auth_type: 'api_key',
|
|
998
|
-
source: 'replit_secret',
|
|
999
|
-
env_var: name,
|
|
1000
|
-
owner: 'user',
|
|
1001
|
-
scope: 'workspace',
|
|
1002
|
-
plan_hint: null,
|
|
1003
|
-
enabled: true,
|
|
1004
|
-
health: 'healthy',
|
|
1005
|
-
last_checked_at: new Date().toISOString(),
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
} catch { /* replit.mjs unavailable */ }
|
|
927
|
+
} catch { /* codex not in PATH */ }
|
|
1010
928
|
|
|
1011
929
|
return found;
|
|
1012
930
|
}
|
|
@@ -1120,12 +1038,12 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
|
1120
1038
|
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
1121
1039
|
|
|
1122
1040
|
try {
|
|
1123
|
-
// available: claude CLI or
|
|
1041
|
+
// available: CLAUDE_CODE env, claude CLI, or replit-tools claude dir
|
|
1124
1042
|
const claudeDir = join(homedir(), '.claude');
|
|
1125
1043
|
const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
|
|
1126
|
-
if (process.env.CLAUDE_CODE
|
|
1044
|
+
if (process.env.CLAUDE_CODE) {
|
|
1127
1045
|
claudeProvider.available = true;
|
|
1128
|
-
claudeProvider.source =
|
|
1046
|
+
claudeProvider.source = 'credentials';
|
|
1129
1047
|
} else if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
|
|
1130
1048
|
claudeProvider.available = true;
|
|
1131
1049
|
claudeProvider.source = existsSync(replitClaudeDir) ? 'replit-tools' : 'credentials';
|
|
@@ -1164,15 +1082,12 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
|
1164
1082
|
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
1165
1083
|
|
|
1166
1084
|
try {
|
|
1167
|
-
let hasSecret = false;
|
|
1168
|
-
try { const { hasSecret: hs } = await import('./replit.mjs'); hasSecret = hs('OPENAI_API_KEY'); } catch { hasSecret = !!(process.env.OPENAI_API_KEY); }
|
|
1169
|
-
|
|
1170
1085
|
let codexAvailable = false;
|
|
1171
1086
|
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); codexAvailable = true; } catch { /* not in PATH */ }
|
|
1172
1087
|
|
|
1173
|
-
openaiProvider.available =
|
|
1174
|
-
openaiProvider.authenticated =
|
|
1175
|
-
openaiProvider.source =
|
|
1088
|
+
openaiProvider.available = codexAvailable;
|
|
1089
|
+
openaiProvider.authenticated = codexAvailable;
|
|
1090
|
+
openaiProvider.source = codexAvailable ? 'codex-cli' : 'none';
|
|
1176
1091
|
} catch { /* detection failed */ }
|
|
1177
1092
|
|
|
1178
1093
|
openaiProvider.plan = normalizePlan(orchProv.openai?.subscription ?? orchSubs.openai?.plan);
|
|
@@ -1387,7 +1302,7 @@ async function main() {
|
|
|
1387
1302
|
`head model : ${getHeadModel(profile)}`,
|
|
1388
1303
|
`providers : ${providers.map(p => p.name).join(', ') || 'none'}`,
|
|
1389
1304
|
`prefs : ${profile.preferences?.filter(p => p.enabled).length || 0} active`,
|
|
1390
|
-
`guardrail :
|
|
1305
|
+
`guardrail : off`,
|
|
1391
1306
|
'',
|
|
1392
1307
|
getOnboardingMessage(caps, profile.workStyle || profile.bias),
|
|
1393
1308
|
].forEach(l => process.stdout.write(l + '\n'));
|
|
@@ -1404,7 +1319,7 @@ export {
|
|
|
1404
1319
|
loadProfile, saveProfile, ensureProfile, runOnboarding,
|
|
1405
1320
|
rememberPreference, forgetPreference, getActivePreferences,
|
|
1406
1321
|
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
1407
|
-
detectCapabilities, getOnboardingMessage,
|
|
1322
|
+
detectCapabilities, getOnboardingMessage,
|
|
1408
1323
|
syncPreferencesToMemory,
|
|
1409
1324
|
detectAuth, detectEnvironment,
|
|
1410
1325
|
autoSetup, autoRefreshToken,
|
package/src/replit.mjs
CHANGED
|
@@ -339,7 +339,7 @@ const SYSTEM_PREFIXES = [
|
|
|
339
339
|
];
|
|
340
340
|
|
|
341
341
|
const KNOWN_SECRET_NAMES = [
|
|
342
|
-
'
|
|
342
|
+
'DATABASE_URL', 'REPLIT_DB_URL',
|
|
343
343
|
'GITHUB_TOKEN', 'GITHUB_API_TOKEN', 'NPM_TOKEN', 'NPM_AUTH_TOKEN',
|
|
344
344
|
'STRIPE_SECRET_KEY', 'STRIPE_API_KEY', 'AWS_ACCESS_KEY_ID',
|
|
345
345
|
'AWS_SECRET_ACCESS_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS',
|
package/src/repo.mjs
CHANGED
|
@@ -283,6 +283,159 @@ export function getLintCommand(cwd = process.cwd()) {
|
|
|
283
283
|
return detectRepo(cwd).commands.lint;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// ─── Ownership hints ──────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Return the last git author, last-modified date, and commit count for a file.
|
|
290
|
+
* @param {string} filePath
|
|
291
|
+
* @param {string} [cwd]
|
|
292
|
+
* @returns {{ lastAuthor: string, lastModified: string, totalCommits: number }|null}
|
|
293
|
+
*/
|
|
294
|
+
export function getFileOwnership(filePath, cwd) {
|
|
295
|
+
try {
|
|
296
|
+
const blame = execSync(`git log --format="%an" -1 -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
|
|
297
|
+
const lastDate = execSync(`git log --format="%ci" -1 -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
|
|
298
|
+
const commitCount = parseInt(execSync(`git rev-list --count HEAD -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim()) || 0;
|
|
299
|
+
return { lastAuthor: blame, lastModified: lastDate, totalCommits: commitCount };
|
|
300
|
+
} catch { return null; }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Dependency edges ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Extract import/require edges from a source file.
|
|
307
|
+
* @param {string} filePath — relative path from cwd
|
|
308
|
+
* @param {string} [cwd]
|
|
309
|
+
* @returns {{ local: string[], external: string[], total: number }}
|
|
310
|
+
*/
|
|
311
|
+
export function getDependencyEdges(filePath, cwd) {
|
|
312
|
+
try {
|
|
313
|
+
const content = readFileSync(join(cwd || process.cwd(), filePath), 'utf8');
|
|
314
|
+
const imports = [];
|
|
315
|
+
// ES module imports
|
|
316
|
+
for (const match of content.matchAll(/import\s+.*?from\s+['"]([^'"]+)['"]/g)) {
|
|
317
|
+
imports.push(match[1]);
|
|
318
|
+
}
|
|
319
|
+
// Dynamic imports
|
|
320
|
+
for (const match of content.matchAll(/import\(['"]([^'"]+)['"]\)/g)) {
|
|
321
|
+
imports.push(match[1]);
|
|
322
|
+
}
|
|
323
|
+
// CommonJS requires
|
|
324
|
+
for (const match of content.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
|
|
325
|
+
imports.push(match[1]);
|
|
326
|
+
}
|
|
327
|
+
const local = imports.filter(i => i.startsWith('.') || i.startsWith('/'));
|
|
328
|
+
const external = imports.filter(i => !i.startsWith('.') && !i.startsWith('/'));
|
|
329
|
+
return { local, external, total: imports.length };
|
|
330
|
+
} catch { return { local: [], external: [], total: 0 }; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Test mapping ─────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Find test files whose name matches the source file's base name.
|
|
337
|
+
* @param {string} filePath
|
|
338
|
+
* @param {string} [cwd]
|
|
339
|
+
* @returns {string[]}
|
|
340
|
+
*/
|
|
341
|
+
export function findRelatedTests(filePath, cwd) {
|
|
342
|
+
const root = cwd || process.cwd();
|
|
343
|
+
const base = filePath.replace(/\.(mjs|js|ts|tsx|jsx)$/, '');
|
|
344
|
+
const name = base.split('/').pop();
|
|
345
|
+
|
|
346
|
+
const found = [];
|
|
347
|
+
try {
|
|
348
|
+
const allTests = execSync(
|
|
349
|
+
`find . -type f \\( -name "*.test.*" -o -name "*.spec.*" -o -path "*/tests/*" -o -path "*/test/*" -o -path "*/__tests__/*" \\) -not -path "*/node_modules/*"`,
|
|
350
|
+
{ cwd: root, encoding: 'utf8', timeout: 5000 }
|
|
351
|
+
).trim().split('\n').filter(Boolean);
|
|
352
|
+
|
|
353
|
+
for (const t of allTests) {
|
|
354
|
+
if (t.includes(name)) found.push(t.replace(/^\.\//, ''));
|
|
355
|
+
}
|
|
356
|
+
} catch {}
|
|
357
|
+
|
|
358
|
+
return found;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Risk hotspots ────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Return the files with highest churn × complexity risk in the last N days.
|
|
365
|
+
* @param {string} [cwd]
|
|
366
|
+
* @param {{ days?: number, limit?: number }} [opts]
|
|
367
|
+
* @returns {Array<{ file: string, changeCount: number, lineCount: number, risk: number }>}
|
|
368
|
+
*/
|
|
369
|
+
export function getRiskHotspots(cwd, opts = {}) {
|
|
370
|
+
const { days = 30, limit = 10 } = opts;
|
|
371
|
+
const root = cwd || process.cwd();
|
|
372
|
+
try {
|
|
373
|
+
const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0];
|
|
374
|
+
const log = execSync(
|
|
375
|
+
`git log --since="${since}" --name-only --pretty=format: | sort | uniq -c | sort -rn | head -${limit * 2}`,
|
|
376
|
+
{ cwd: root, encoding: 'utf8', timeout: 10000 }
|
|
377
|
+
).trim();
|
|
378
|
+
|
|
379
|
+
const hotspots = [];
|
|
380
|
+
for (const line of log.split('\n').filter(Boolean)) {
|
|
381
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
382
|
+
if (match) {
|
|
383
|
+
const changeCount = parseInt(match[1]);
|
|
384
|
+
const file = match[2];
|
|
385
|
+
if (changeCount >= 3 && existsSync(join(root, file))) {
|
|
386
|
+
let lineCount = 0;
|
|
387
|
+
try {
|
|
388
|
+
lineCount = readFileSync(join(root, file), 'utf8').split('\n').length;
|
|
389
|
+
} catch {}
|
|
390
|
+
hotspots.push({ file, changeCount, lineCount, risk: changeCount * Math.log2(Math.max(lineCount, 1)) });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return hotspots.sort((a, b) => b.risk - a.risk).slice(0, limit);
|
|
396
|
+
} catch { return []; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Primary language detection ───────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
function detectPrimaryLanguage(cwd) {
|
|
402
|
+
try {
|
|
403
|
+
const files = execSync(
|
|
404
|
+
'git ls-files --cached | grep -oE "\\.[a-zA-Z]+$" | sort | uniq -c | sort -rn | head -5',
|
|
405
|
+
{ cwd, encoding: 'utf8', timeout: 5000 }
|
|
406
|
+
).trim();
|
|
407
|
+
const match = files.split('\n')[0]?.trim().match(/^\d+\s+\.(.+)$/);
|
|
408
|
+
const ext = match?.[1];
|
|
409
|
+
const langMap = {
|
|
410
|
+
js: 'JavaScript', mjs: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript',
|
|
411
|
+
py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java',
|
|
412
|
+
kt: 'Kotlin', swift: 'Swift', cpp: 'C++', c: 'C',
|
|
413
|
+
};
|
|
414
|
+
return langMap[ext] || ext || 'unknown';
|
|
415
|
+
} catch { return 'unknown'; }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Repo intelligence ────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Return consolidated repo intelligence for routing decisions.
|
|
422
|
+
* @param {string} [cwd]
|
|
423
|
+
* @returns {object}
|
|
424
|
+
*/
|
|
425
|
+
export function getRepoIntelligence(cwd) {
|
|
426
|
+
const root = cwd || process.cwd();
|
|
427
|
+
const cache = loadRepoCache(root);
|
|
428
|
+
const hotspots = getRiskHotspots(root);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
...cache,
|
|
432
|
+
hotspots,
|
|
433
|
+
hasTests: hotspots.some(h => h.file.includes('test')),
|
|
434
|
+
primaryLanguage: detectPrimaryLanguage(root),
|
|
435
|
+
repoSize: cache?.fileCount || 0,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
286
439
|
// ─── CLI (direct invocation) ──────────────────────────────────────────────────
|
|
287
440
|
|
|
288
441
|
const isMain = process.argv[1]?.endsWith('repo.mjs');
|