dual-brain 0.2.4 → 0.2.5
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 +349 -2
- package/package.json +10 -2
- package/src/awareness.mjs +36 -0
- 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 -6
- package/src/health.mjs +35 -0
- package/src/pipeline.mjs +58 -1
- package/src/pr-agent.mjs +214 -0
- package/src/repo.mjs +153 -0
package/src/health.mjs
CHANGED
|
@@ -314,6 +314,41 @@ export function resetHealth(cwd) {
|
|
|
314
314
|
saveRaw({ states: {}, session: null }, cwd);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// ─── Network timeout guard ────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Ping a provider URL with a bounded timeout so slow networks don't hang the CLI.
|
|
321
|
+
*
|
|
322
|
+
* Uses AbortController to enforce the deadline. On timeout or network error the
|
|
323
|
+
* caller receives { ok: false, status: 'timeout' } rather than hanging forever.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} url
|
|
326
|
+
* @param {{ timeoutMs?: number, headers?: Record<string,string> }} [opts]
|
|
327
|
+
* @returns {Promise<{ ok: boolean, status: 'ok'|'timeout'|'error', detail?: string }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function pingProvider(url, opts = {}) {
|
|
330
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
331
|
+
const controller = new AbortController();
|
|
332
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetch(url, {
|
|
335
|
+
method: 'HEAD',
|
|
336
|
+
signal: controller.signal,
|
|
337
|
+
headers: opts.headers ?? {},
|
|
338
|
+
});
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
return { ok: res.ok, status: 'ok', detail: String(res.status) };
|
|
341
|
+
} catch (err) {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
const isTimeout = err?.name === 'AbortError';
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
status: isTimeout ? 'timeout' : 'error',
|
|
347
|
+
detail: isTimeout ? `Provider health: unknown (timeout after ${timeoutMs}ms)` : String(err?.message),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
317
352
|
// ─── Remaining cooldown helper (used by status display) ──────────────────────
|
|
318
353
|
|
|
319
354
|
/**
|
package/src/pipeline.mjs
CHANGED
|
@@ -84,6 +84,9 @@ export function createPipelineRun(trigger = '', prompt = '') {
|
|
|
84
84
|
replitTools: null, // from replit.inspectReplitTools()
|
|
85
85
|
replitConfig: null, // from replit.getReplitToolsConfig()
|
|
86
86
|
|
|
87
|
+
// Execution safety (populated in Phase 3 when risk is high/critical)
|
|
88
|
+
checkpoint: null, // from checkpoint.mjs — { success, id, label, timestamp } or null
|
|
89
|
+
|
|
87
90
|
completedAt: null,
|
|
88
91
|
};
|
|
89
92
|
}
|
|
@@ -978,11 +981,28 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
978
981
|
|
|
979
982
|
// ── Phase 3: Execute ──────────────────────────────────────────────────────
|
|
980
983
|
|
|
981
|
-
// Checkpoint (best-effort, before execute)
|
|
984
|
+
// Checkpoint (best-effort, before execute).
|
|
985
|
+
// The pipeline-internal createCheckpoint handles git stash/HEAD recording.
|
|
986
|
+
// Additionally, use the dedicated checkpoint.mjs module for high/critical risk
|
|
987
|
+
// tasks so the result is surfaced in the run object.
|
|
982
988
|
if (run.plan.checkpointRequired) {
|
|
983
989
|
await createCheckpoint(cwd, run.context);
|
|
984
990
|
}
|
|
985
991
|
|
|
992
|
+
const detectedRisk = run.context?.detection?.risk ?? 'low';
|
|
993
|
+
if (detectedRisk === 'high' || detectedRisk === 'critical') {
|
|
994
|
+
try {
|
|
995
|
+
const { createCheckpoint: cpCreate } = await import('./checkpoint.mjs');
|
|
996
|
+
const cpLabel = `before: ${prompt.slice(0, 80)}`;
|
|
997
|
+
const cpResult = cpCreate(cpLabel, { cwd });
|
|
998
|
+
run.checkpoint = cpResult;
|
|
999
|
+
if (verbose) log(`[pipeline] checkpoint created: ${cpResult.id} (${cpResult.success ? 'ok' : 'failed'})`);
|
|
1000
|
+
} catch {
|
|
1001
|
+
// checkpoint.mjs unavailable — non-blocking
|
|
1002
|
+
run.checkpoint = null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
986
1006
|
const decision = { ...run.plan._decision };
|
|
987
1007
|
|
|
988
1008
|
run.result = await dispatch({
|
|
@@ -1157,6 +1177,41 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1157
1177
|
}
|
|
1158
1178
|
}
|
|
1159
1179
|
|
|
1180
|
+
// Continuity handoff — generate and persist a compact receipt so the next
|
|
1181
|
+
// session can resume seamlessly (survives context limits and crashes).
|
|
1182
|
+
try {
|
|
1183
|
+
const { generateHandoff, saveHandoff, pruneHandoffs } = await import('./continuity.mjs');
|
|
1184
|
+
const handoffCwd = options.cwd || process.cwd();
|
|
1185
|
+
|
|
1186
|
+
const sessionState = {
|
|
1187
|
+
taskDescription: prompt.slice(0, 200),
|
|
1188
|
+
filesChanged: run.result?.filesChanged || run.plan?.targetFiles || [],
|
|
1189
|
+
testsRun: run.verification?.notes || [],
|
|
1190
|
+
decisions: run.plan ? [{
|
|
1191
|
+
provider: run.plan.primaryProvider,
|
|
1192
|
+
model: run.plan.primaryModel,
|
|
1193
|
+
tier: run.plan.tier,
|
|
1194
|
+
reasoningDepth: run.plan.reasoningDepth,
|
|
1195
|
+
}] : [],
|
|
1196
|
+
unresolved: run.contradictions?.filter(c => c.severity !== 'block').map(c => c.message) || [],
|
|
1197
|
+
routingHistory: {
|
|
1198
|
+
lastProvider: run.result?.provider || run.plan?.primaryProvider || null,
|
|
1199
|
+
lastModel: run.result?.model || run.plan?.primaryModel || null,
|
|
1200
|
+
failedProviders: run.result?.error ? [run.plan?.primaryProvider].filter(Boolean) : [],
|
|
1201
|
+
},
|
|
1202
|
+
activePreferences: run.context?.profile?.preferences || [],
|
|
1203
|
+
resumeHint: run.result && !run.result?.error
|
|
1204
|
+
? null
|
|
1205
|
+
: `retry: ${prompt.slice(0, 100)}`,
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
const handoff = generateHandoff(sessionState);
|
|
1209
|
+
saveHandoff(handoff, handoffCwd);
|
|
1210
|
+
pruneHandoffs(handoffCwd, 10); // keep last 10 handoffs
|
|
1211
|
+
} catch {
|
|
1212
|
+
// continuity is best-effort — never block pipeline completion
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1160
1215
|
} catch (err) {
|
|
1161
1216
|
log(`[pipeline] error in pipeline step: ${err.message}`);
|
|
1162
1217
|
run.result = { status: 'error', error: err.message };
|
|
@@ -1180,6 +1235,8 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1180
1235
|
modelSuggestion: run.modelSuggestion,
|
|
1181
1236
|
thinkResult: run.thinkResult,
|
|
1182
1237
|
decisionPreflight: run.decisionPreflight,
|
|
1238
|
+
// Execution safety
|
|
1239
|
+
checkpoint: run.checkpoint,
|
|
1183
1240
|
// Legacy compatibility
|
|
1184
1241
|
plan: run.plan,
|
|
1185
1242
|
result: run.result,
|
package/src/pr-agent.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// pr-agent.mjs — PR workflow module for dual-brain.
|
|
2
|
+
// Provides issue/task → branch → implement → PR automation using the gh CLI.
|
|
3
|
+
// Exports: hasGitHub, getBranchInfo, createBranch, getDiffSummary, createPR,
|
|
4
|
+
// listPRs, getPRDetails, buildPRBody
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if gh CLI is available and authenticated.
|
|
12
|
+
* @returns {{ available: boolean, authenticated: boolean }}
|
|
13
|
+
*/
|
|
14
|
+
export function hasGitHub() {
|
|
15
|
+
try {
|
|
16
|
+
execSync('gh auth status', { stdio: 'pipe', timeout: 5000 });
|
|
17
|
+
return { available: true, authenticated: true };
|
|
18
|
+
} catch {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which gh', { stdio: 'pipe', timeout: 2000 });
|
|
21
|
+
return { available: true, authenticated: false };
|
|
22
|
+
} catch {
|
|
23
|
+
return { available: false, authenticated: false };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get current branch info including distance from default branch.
|
|
30
|
+
* @param {string} [cwd]
|
|
31
|
+
* @returns {{ branch: string|null, defaultBranch: string, ahead: number, behind: number, isDefault: boolean }}
|
|
32
|
+
*/
|
|
33
|
+
export function getBranchInfo(cwd) {
|
|
34
|
+
const dir = cwd ?? process.cwd();
|
|
35
|
+
try {
|
|
36
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim();
|
|
37
|
+
const defaultBranch = execSync(
|
|
38
|
+
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main',
|
|
39
|
+
{ cwd: dir, encoding: 'utf8', timeout: 3000 },
|
|
40
|
+
).trim().replace('refs/remotes/origin/', '');
|
|
41
|
+
const ahead = parseInt(
|
|
42
|
+
execSync(`git rev-list --count ${defaultBranch}..HEAD 2>/dev/null || echo 0`, { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
43
|
+
) || 0;
|
|
44
|
+
const behind = parseInt(
|
|
45
|
+
execSync(`git rev-list --count HEAD..${defaultBranch} 2>/dev/null || echo 0`, { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
46
|
+
) || 0;
|
|
47
|
+
return { branch, defaultBranch, ahead, behind, isDefault: branch === defaultBranch };
|
|
48
|
+
} catch {
|
|
49
|
+
return { branch: null, defaultBranch: 'main', ahead: 0, behind: 0, isDefault: true };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a feature branch from a task description.
|
|
55
|
+
* Branch name is prefixed with "db/" and slugified from the description.
|
|
56
|
+
* @param {string} taskDescription
|
|
57
|
+
* @param {string} [cwd]
|
|
58
|
+
* @returns {{ success: boolean, branch: string, error?: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function createBranch(taskDescription, cwd) {
|
|
61
|
+
const dir = cwd ?? process.cwd();
|
|
62
|
+
const slug = taskDescription
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/\s+/g, '-')
|
|
67
|
+
.slice(0, 50);
|
|
68
|
+
const branchName = `db/${slug}`;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
execSync(`git checkout -b "${branchName}"`, { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
|
72
|
+
return { success: true, branch: branchName };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { success: false, branch: branchName, error: err.message };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get diff summary for PR description generation.
|
|
80
|
+
* @param {string} baseBranch Base branch name (e.g. 'main')
|
|
81
|
+
* @param {string} [cwd]
|
|
82
|
+
* @returns {{ stat: string, files: string[], summary: string, fileCount: number }}
|
|
83
|
+
*/
|
|
84
|
+
export function getDiffSummary(baseBranch, cwd) {
|
|
85
|
+
const dir = cwd ?? process.cwd();
|
|
86
|
+
try {
|
|
87
|
+
const stat = execSync(`git diff --stat ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 10000 }).trim();
|
|
88
|
+
const files = execSync(`git diff --name-only ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 5000 })
|
|
89
|
+
.trim()
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
const summary = execSync(`git diff --shortstat ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 5000 }).trim();
|
|
93
|
+
return { stat, files, summary, fileCount: files.length };
|
|
94
|
+
} catch {
|
|
95
|
+
return { stat: '', files: [], summary: '', fileCount: 0 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a PR using the gh CLI. Pushes the current branch first.
|
|
101
|
+
* @param {object} opts
|
|
102
|
+
* @param {string} opts.title
|
|
103
|
+
* @param {string} opts.body
|
|
104
|
+
* @param {string} [opts.baseBranch]
|
|
105
|
+
* @param {boolean} [opts.draft]
|
|
106
|
+
* @param {string[]} [opts.labels]
|
|
107
|
+
* @param {string} [opts.cwd]
|
|
108
|
+
* @returns {{ success: boolean, url?: string, error?: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function createPR(opts) {
|
|
111
|
+
const { title, body, baseBranch, draft, labels, cwd } = opts;
|
|
112
|
+
const dir = cwd ?? process.cwd();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Push current branch to origin first
|
|
116
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim();
|
|
117
|
+
execSync(`git push -u origin "${branch}"`, { cwd: dir, stdio: 'pipe', timeout: 30000 });
|
|
118
|
+
|
|
119
|
+
// Build gh pr create args
|
|
120
|
+
const args = ['gh', 'pr', 'create', '--title', JSON.stringify(title), '--body', JSON.stringify(body)];
|
|
121
|
+
if (baseBranch) args.push('--base', baseBranch);
|
|
122
|
+
if (draft) args.push('--draft');
|
|
123
|
+
if (labels?.length) args.push('--label', labels.join(','));
|
|
124
|
+
|
|
125
|
+
const result = execSync(args.join(' '), { cwd: dir, encoding: 'utf8', timeout: 30000 });
|
|
126
|
+
const url = result.trim();
|
|
127
|
+
return { success: true, url };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { success: false, error: err.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List open (or other state) PRs for the current repo.
|
|
135
|
+
* @param {string} [cwd]
|
|
136
|
+
* @param {object} [opts]
|
|
137
|
+
* @param {'open'|'closed'|'merged'|'all'} [opts.state]
|
|
138
|
+
* @param {number} [opts.limit]
|
|
139
|
+
* @returns {object[]}
|
|
140
|
+
*/
|
|
141
|
+
export function listPRs(cwd, opts = {}) {
|
|
142
|
+
const dir = cwd ?? process.cwd();
|
|
143
|
+
const { state = 'open', limit = 10 } = opts;
|
|
144
|
+
try {
|
|
145
|
+
const json = execSync(
|
|
146
|
+
`gh pr list --state ${state} --limit ${limit} --json number,title,headRefName,author,createdAt,isDraft`,
|
|
147
|
+
{ cwd: dir, encoding: 'utf8', timeout: 10000 },
|
|
148
|
+
);
|
|
149
|
+
return JSON.parse(json);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get PR details including diff stats, comments, and CI checks.
|
|
157
|
+
* @param {number|string} prNumber
|
|
158
|
+
* @param {string} [cwd]
|
|
159
|
+
* @returns {object|null}
|
|
160
|
+
*/
|
|
161
|
+
export function getPRDetails(prNumber, cwd) {
|
|
162
|
+
const dir = cwd ?? process.cwd();
|
|
163
|
+
try {
|
|
164
|
+
const json = execSync(
|
|
165
|
+
`gh pr view ${prNumber} --json title,body,headRefName,baseRefName,state,additions,deletions,changedFiles,reviews,comments,statusCheckRollup`,
|
|
166
|
+
{ cwd: dir, encoding: 'utf8', timeout: 10000 },
|
|
167
|
+
);
|
|
168
|
+
return JSON.parse(json);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a PR body from a task description and dispatch results.
|
|
176
|
+
* @param {string} taskDescription
|
|
177
|
+
* @param {object} results Dispatch result object (filesChanged, testsRun, decisions, etc.)
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
export function buildPRBody(taskDescription, results) {
|
|
181
|
+
const lines = [];
|
|
182
|
+
lines.push('## Summary');
|
|
183
|
+
lines.push(taskDescription);
|
|
184
|
+
lines.push('');
|
|
185
|
+
|
|
186
|
+
if (results.filesChanged?.length) {
|
|
187
|
+
lines.push('## Changes');
|
|
188
|
+
for (const f of results.filesChanged) {
|
|
189
|
+
lines.push(`- \`${f}\``);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (results.testsRun?.length) {
|
|
195
|
+
lines.push('## Tests');
|
|
196
|
+
for (const t of results.testsRun) {
|
|
197
|
+
lines.push(`- ${t}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (results.decisions?.length) {
|
|
203
|
+
lines.push('## Routing');
|
|
204
|
+
for (const d of results.decisions) {
|
|
205
|
+
lines.push(`- ${d}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push('');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push('---');
|
|
211
|
+
lines.push('Generated by [dual-brain](https://npmjs.com/package/dual-brain)');
|
|
212
|
+
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
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');
|