dsh-codebase-chat 0.22.0 → 0.24.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/README.md +189 -200
- package/dist/cli.js +369 -62
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +64 -2
- package/dist/index.js +364 -61
- package/dist/index.js.map +1 -1
- package/lib/index.js +96 -20
- package/package.json +12 -10
package/dist/index.js
CHANGED
|
@@ -647,7 +647,12 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
647
647
|
".vscode",
|
|
648
648
|
"__pycache__",
|
|
649
649
|
".dsh-tmp",
|
|
650
|
-
".dsh-vision-router"
|
|
650
|
+
".dsh-vision-router",
|
|
651
|
+
".agents",
|
|
652
|
+
".claude",
|
|
653
|
+
".devin",
|
|
654
|
+
".playwright-mcp",
|
|
655
|
+
".windsurf"
|
|
651
656
|
]);
|
|
652
657
|
var DEFAULT_SKIP_FILES = /* @__PURE__ */ new Set([]);
|
|
653
658
|
async function getWalkOptions(absProject) {
|
|
@@ -662,11 +667,10 @@ function projectHash(absProject) {
|
|
|
662
667
|
return createHash("sha256").update(absProject.toLowerCase()).digest("hex").slice(0, 16);
|
|
663
668
|
}
|
|
664
669
|
function resolveProjectPath(projectPath) {
|
|
665
|
-
const raw = (projectPath ?? "").trim().
|
|
666
|
-
if (raw
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
return resolve(process.cwd(), projectPath);
|
|
670
|
+
const raw = (projectPath ?? "").trim().replace(/['"]/g, "");
|
|
671
|
+
if (!raw) return process.cwd();
|
|
672
|
+
if (isAbsolute(raw)) return resolve(raw);
|
|
673
|
+
return resolve(process.cwd(), raw);
|
|
670
674
|
}
|
|
671
675
|
async function findProjectRoot(absProject) {
|
|
672
676
|
try {
|
|
@@ -1102,9 +1106,12 @@ function lexicalScore(index, query) {
|
|
|
1102
1106
|
const terms = tokenizeQuery(query);
|
|
1103
1107
|
const scores = /* @__PURE__ */ new Map();
|
|
1104
1108
|
if (terms.length === 0) return scores;
|
|
1109
|
+
const totalFiles = Math.max(Object.keys(index.files).length, 1);
|
|
1105
1110
|
for (const term of terms) {
|
|
1106
1111
|
const posting = index.terms[term];
|
|
1107
1112
|
if (!posting) continue;
|
|
1113
|
+
const df = Object.keys(posting).length;
|
|
1114
|
+
const idf = Math.log(1 + totalFiles / df);
|
|
1108
1115
|
for (const [relPath, count] of Object.entries(posting)) {
|
|
1109
1116
|
const file = index.files[relPath];
|
|
1110
1117
|
if (!file) continue;
|
|
@@ -1115,7 +1122,7 @@ function lexicalScore(index, query) {
|
|
|
1115
1122
|
if (!contentHit && !nameHit) continue;
|
|
1116
1123
|
const bonus = (nameHit ? 4 : 0) + (chunk.kind === "function" || chunk.kind === "method" ? 1 : 0) + (contentHit ? 1 : 0);
|
|
1117
1124
|
const prev = scores.get(key) ?? 0;
|
|
1118
|
-
scores.set(key, prev + count + bonus);
|
|
1125
|
+
scores.set(key, prev + count * idf + bonus);
|
|
1119
1126
|
}
|
|
1120
1127
|
}
|
|
1121
1128
|
}
|
|
@@ -1168,6 +1175,33 @@ function selectChunks(scored, maxTokens, maxChunkTokens = Infinity) {
|
|
|
1168
1175
|
return { chunks: result, tokens: used };
|
|
1169
1176
|
}
|
|
1170
1177
|
|
|
1178
|
+
// src/diff.ts
|
|
1179
|
+
import { execFile } from "child_process";
|
|
1180
|
+
import { sep as sep3 } from "path";
|
|
1181
|
+
import { promisify } from "util";
|
|
1182
|
+
var run = promisify(execFile);
|
|
1183
|
+
async function git(absProject, args) {
|
|
1184
|
+
const { stdout } = await run("git", ["-C", absProject, ...args], { maxBuffer: 16 * 1024 * 1024 });
|
|
1185
|
+
return stdout;
|
|
1186
|
+
}
|
|
1187
|
+
function toRelList(output) {
|
|
1188
|
+
return output.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => l.split(sep3).join("/"));
|
|
1189
|
+
}
|
|
1190
|
+
async function getChangedFiles(absProject, base) {
|
|
1191
|
+
try {
|
|
1192
|
+
const [tracked, untracked] = await Promise.all([
|
|
1193
|
+
// Working tree vs base — covers committed, staged and unstaged edits.
|
|
1194
|
+
git(absProject, ["diff", "--name-only", base, "--"]),
|
|
1195
|
+
git(absProject, ["ls-files", "--others", "--exclude-standard"])
|
|
1196
|
+
]);
|
|
1197
|
+
const files = /* @__PURE__ */ new Set([...toRelList(tracked), ...toRelList(untracked)]);
|
|
1198
|
+
return { files, ok: true };
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1201
|
+
return { files: /* @__PURE__ */ new Set(), ok: false, error: msg.split("\n")[0] };
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1171
1205
|
// src/context.ts
|
|
1172
1206
|
var DEFAULT_MAX_TOKENS = 6e4;
|
|
1173
1207
|
var HEAD_BUDGET_TOKENS = 800;
|
|
@@ -1178,23 +1212,38 @@ function getLabels(lang) {
|
|
|
1178
1212
|
tree: "File tree",
|
|
1179
1213
|
noConstraints: "No explicit constraints documented.",
|
|
1180
1214
|
constraints: "IDENTIFIED PRODUCT CONSTRAINTS",
|
|
1181
|
-
answerIn: "Answer in English."
|
|
1215
|
+
answerIn: "Answer in English.",
|
|
1216
|
+
diffScope: (base, n) => `Scope: ${n} file(s) changed vs ${base}`,
|
|
1217
|
+
diffUnavailable: (base) => `Scope: diff vs ${base} unavailable (not a git repo?) \u2014 full project`
|
|
1182
1218
|
} : {
|
|
1183
1219
|
project: "Projet",
|
|
1184
1220
|
focus: "Focus",
|
|
1185
1221
|
tree: "Arborescence",
|
|
1186
1222
|
noConstraints: "Aucune contrainte explicite document\xE9e.",
|
|
1187
1223
|
constraints: "CONTRAINTES PRODUIT IDENTIFI\xC9ES",
|
|
1188
|
-
answerIn: "R\xE9ponds obligatoirement en fran\xE7ais."
|
|
1224
|
+
answerIn: "R\xE9ponds obligatoirement en fran\xE7ais.",
|
|
1225
|
+
diffScope: (base, n) => `P\xE9rim\xE8tre : ${n} fichier(s) modifi\xE9(s) vs ${base}`,
|
|
1226
|
+
diffUnavailable: (base) => `P\xE9rim\xE8tre : diff vs ${base} indisponible (pas un repo git ?) \u2014 projet complet`
|
|
1189
1227
|
};
|
|
1190
1228
|
}
|
|
1191
1229
|
async function extractProductConstraints(absProject) {
|
|
1192
|
-
const candidates = [
|
|
1230
|
+
const candidates = [
|
|
1231
|
+
"README.md",
|
|
1232
|
+
"README.MD",
|
|
1233
|
+
"readme.md",
|
|
1234
|
+
"MEMORY.md",
|
|
1235
|
+
"CONTRIBUTING.md",
|
|
1236
|
+
"AGENTS.md",
|
|
1237
|
+
"CLAUDE.md",
|
|
1238
|
+
".windsurfrules",
|
|
1239
|
+
".cursorrules",
|
|
1240
|
+
".cursorrules.md"
|
|
1241
|
+
];
|
|
1193
1242
|
const constraints = [];
|
|
1194
1243
|
for (const name of candidates) {
|
|
1195
1244
|
const text = await safeReadText(join5(absProject, name));
|
|
1196
1245
|
if (!text) continue;
|
|
1197
|
-
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation)[\s\S]{0,200}/gi;
|
|
1246
|
+
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation|never|always|jamais|toujours|zéro|zero)[\s\S]{0,200}/gi;
|
|
1198
1247
|
let m;
|
|
1199
1248
|
while ((m = regex.exec(text)) !== null) {
|
|
1200
1249
|
const line = m[0].replace(/\s+/g, " ").trim();
|
|
@@ -1212,13 +1261,30 @@ function formatChunk(chunk) {
|
|
|
1212
1261
|
${chunk.content}`;
|
|
1213
1262
|
}
|
|
1214
1263
|
async function buildContext(options) {
|
|
1215
|
-
const { project, query, filePath, searchQuery, instruction, embed = false } = options;
|
|
1264
|
+
const { project, query, filePath, searchQuery, instruction, embed = false, diff } = options;
|
|
1216
1265
|
const absProject = await findProjectRoot(resolveProjectPath(project));
|
|
1217
1266
|
const cfg = await loadProjectConfig(absProject);
|
|
1218
1267
|
const maxTokens = options.maxTokens ?? cfg.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
1219
1268
|
const lang = options.lang ?? cfg.lang ?? "fr";
|
|
1220
1269
|
const labels = getLabels(lang);
|
|
1221
1270
|
const index = await getIndex(absProject);
|
|
1271
|
+
let scopedIndex = index;
|
|
1272
|
+
let diffFiles;
|
|
1273
|
+
let scopeLine = "";
|
|
1274
|
+
if (diff && !filePath) {
|
|
1275
|
+
const scope = await getChangedFiles(absProject, diff);
|
|
1276
|
+
if (scope.ok) {
|
|
1277
|
+
diffFiles = [...scope.files].filter((f) => index.files[f]);
|
|
1278
|
+
const files = {};
|
|
1279
|
+
for (const f of diffFiles) files[f] = index.files[f];
|
|
1280
|
+
scopedIndex = { ...index, files };
|
|
1281
|
+
scopeLine = `${labels.diffScope(diff, diffFiles.length)}
|
|
1282
|
+
`;
|
|
1283
|
+
} else {
|
|
1284
|
+
scopeLine = `${labels.diffUnavailable(diff)}
|
|
1285
|
+
`;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1222
1288
|
if (embed) {
|
|
1223
1289
|
try {
|
|
1224
1290
|
await embedIndex(index);
|
|
@@ -1240,17 +1306,17 @@ async function buildContext(options) {
|
|
|
1240
1306
|
);
|
|
1241
1307
|
selectedChunks = chunks;
|
|
1242
1308
|
} else {
|
|
1243
|
-
const scored = await scoreChunks(
|
|
1309
|
+
const scored = await scoreChunks(scopedIndex, filePath, embed);
|
|
1244
1310
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1245
1311
|
if (chunks.length === 0) throw new Error(`File not found: ${filePath}`);
|
|
1246
1312
|
selectedChunks = chunks;
|
|
1247
1313
|
}
|
|
1248
1314
|
} else if (searchQuery) {
|
|
1249
|
-
const scored = await scoreChunks(
|
|
1315
|
+
const scored = await scoreChunks(scopedIndex, searchQuery, embed);
|
|
1250
1316
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1251
1317
|
selectedChunks = chunks;
|
|
1252
1318
|
} else {
|
|
1253
|
-
const scored = await scoreChunks(
|
|
1319
|
+
const scored = await scoreChunks(scopedIndex, query, embed);
|
|
1254
1320
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1255
1321
|
selectedChunks = chunks;
|
|
1256
1322
|
}
|
|
@@ -1260,7 +1326,7 @@ ${constraints.map((c) => `- ${c}`).join("\n")}` : `== ${labels.constraints} ==
|
|
|
1260
1326
|
${labels.noConstraints}`;
|
|
1261
1327
|
const head = `${labels.project} : ${absProject}
|
|
1262
1328
|
${labels.focus} : ${focus}
|
|
1263
|
-
${constraintsText}
|
|
1329
|
+
${scopeLine}${constraintsText}
|
|
1264
1330
|
|
|
1265
1331
|
== ${labels.tree} ==
|
|
1266
1332
|
${index.tree}
|
|
@@ -1284,12 +1350,13 @@ ${finalInstruction}`;
|
|
|
1284
1350
|
absProject,
|
|
1285
1351
|
context: prompt,
|
|
1286
1352
|
chunks: selectedChunks,
|
|
1287
|
-
tokenCount
|
|
1353
|
+
tokenCount,
|
|
1354
|
+
diffFiles
|
|
1288
1355
|
};
|
|
1289
1356
|
}
|
|
1290
1357
|
|
|
1291
1358
|
// src/analysis.ts
|
|
1292
|
-
import { basename, extname as extname2, join as join6, relative as relative3, sep as
|
|
1359
|
+
import { basename, extname as extname2, join as join6, relative as relative3, sep as sep4, posix as posixPath } from "path";
|
|
1293
1360
|
import { readFile as readFile4 } from "fs/promises";
|
|
1294
1361
|
var CODE_EXTS = /* @__PURE__ */ new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
|
1295
1362
|
var ENTRY_BASENAMES = /* @__PURE__ */ new Set(["index", "main", "app", "cli", "server", "bin", "mod"]);
|
|
@@ -1332,36 +1399,58 @@ function parseExports(text) {
|
|
|
1332
1399
|
}
|
|
1333
1400
|
function findCycles(edges) {
|
|
1334
1401
|
const adj = /* @__PURE__ */ new Map();
|
|
1402
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
1335
1403
|
for (const e of edges) {
|
|
1404
|
+
nodes.add(e.from);
|
|
1405
|
+
nodes.add(e.to);
|
|
1336
1406
|
if (!adj.has(e.from)) adj.set(e.from, []);
|
|
1337
1407
|
adj.get(e.from).push(e.to);
|
|
1338
1408
|
}
|
|
1339
|
-
const
|
|
1340
|
-
const
|
|
1341
|
-
const stack = [];
|
|
1409
|
+
const index = /* @__PURE__ */ new Map();
|
|
1410
|
+
const low = /* @__PURE__ */ new Map();
|
|
1342
1411
|
const onStack = /* @__PURE__ */ new Set();
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1412
|
+
const stack = [];
|
|
1413
|
+
const sccs = [];
|
|
1414
|
+
let counter = 0;
|
|
1415
|
+
for (const root of nodes) {
|
|
1416
|
+
if (index.has(root)) continue;
|
|
1417
|
+
const work = [[root, 0]];
|
|
1418
|
+
while (work.length) {
|
|
1419
|
+
const top = work[work.length - 1];
|
|
1420
|
+
const [v, ci] = top;
|
|
1421
|
+
if (ci === 0) {
|
|
1422
|
+
index.set(v, counter);
|
|
1423
|
+
low.set(v, counter);
|
|
1424
|
+
counter++;
|
|
1425
|
+
stack.push(v);
|
|
1426
|
+
onStack.add(v);
|
|
1427
|
+
}
|
|
1428
|
+
const children = adj.get(v) ?? [];
|
|
1429
|
+
if (ci < children.length) {
|
|
1430
|
+
top[1] = ci + 1;
|
|
1431
|
+
const w = children[ci];
|
|
1432
|
+
if (!index.has(w)) work.push([w, 0]);
|
|
1433
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
1434
|
+
} else {
|
|
1435
|
+
work.pop();
|
|
1436
|
+
if (work.length) {
|
|
1437
|
+
const parent = work[work.length - 1][0];
|
|
1438
|
+
low.set(parent, Math.min(low.get(parent), low.get(v)));
|
|
1439
|
+
}
|
|
1440
|
+
if (low.get(v) === index.get(v)) {
|
|
1441
|
+
const scc = [];
|
|
1442
|
+
let w;
|
|
1443
|
+
do {
|
|
1444
|
+
w = stack.pop();
|
|
1445
|
+
onStack.delete(w);
|
|
1446
|
+
scc.push(w);
|
|
1447
|
+
} while (w !== v);
|
|
1448
|
+
if (scc.length > 1 || (adj.get(v) ?? []).includes(v)) sccs.push(scc);
|
|
1355
1449
|
}
|
|
1356
|
-
} else if (!stack.includes(next)) {
|
|
1357
|
-
dfs(next);
|
|
1358
1450
|
}
|
|
1359
1451
|
}
|
|
1360
|
-
stack.pop();
|
|
1361
|
-
onStack.delete(node);
|
|
1362
1452
|
}
|
|
1363
|
-
|
|
1364
|
-
return cycles;
|
|
1453
|
+
return sccs.map((m) => ({ path: m.sort() })).sort((a, b) => b.path.length - a.path.length);
|
|
1365
1454
|
}
|
|
1366
1455
|
function looksLikeEntry(rel, pkg) {
|
|
1367
1456
|
const base = basename(rel).toLowerCase().replace(extname2(rel), "");
|
|
@@ -1404,13 +1493,13 @@ function findDuplicates(fileTexts) {
|
|
|
1404
1493
|
}
|
|
1405
1494
|
return [...groups.values()].sort((a, b) => b.lines - a.lines).slice(0, 15);
|
|
1406
1495
|
}
|
|
1407
|
-
async function
|
|
1496
|
+
async function collectImportGraph(projectPath) {
|
|
1408
1497
|
const abs = await findProjectRoot(resolveProjectPath(projectPath));
|
|
1409
1498
|
const fileTexts = /* @__PURE__ */ new Map();
|
|
1410
1499
|
const codeFiles = [];
|
|
1411
1500
|
const walk = await getWalkOptions(abs);
|
|
1412
1501
|
for await (const full of walkFiles(abs, walk.skipDirs, walk.skipFiles, walk.ignoreGlobs)) {
|
|
1413
|
-
const rel = relative3(abs, full).split(
|
|
1502
|
+
const rel = relative3(abs, full).split(sep4).join("/");
|
|
1414
1503
|
const ext = extname2(rel).toLowerCase();
|
|
1415
1504
|
if (!CODE_EXTS.has(ext) || SKIP_EXTS.has(ext) || rel.includes(".min.")) continue;
|
|
1416
1505
|
const text = await safeReadText(full);
|
|
@@ -1418,11 +1507,6 @@ async function analyzeProject(projectPath) {
|
|
|
1418
1507
|
codeFiles.push(rel);
|
|
1419
1508
|
fileTexts.set(rel, text);
|
|
1420
1509
|
}
|
|
1421
|
-
let pkg = {};
|
|
1422
|
-
try {
|
|
1423
|
-
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1424
|
-
} catch {
|
|
1425
|
-
}
|
|
1426
1510
|
const known = new Set(codeFiles);
|
|
1427
1511
|
const edges = [];
|
|
1428
1512
|
const inDegree = /* @__PURE__ */ new Map();
|
|
@@ -1432,18 +1516,32 @@ async function analyzeProject(projectPath) {
|
|
|
1432
1516
|
inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
|
|
1433
1517
|
}
|
|
1434
1518
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1519
|
+
return { abs, codeFiles, fileTexts, edges, inDegree };
|
|
1520
|
+
}
|
|
1521
|
+
async function analyzeProject(projectPath, opts = {}) {
|
|
1522
|
+
const { abs, codeFiles, fileTexts, edges, inDegree } = await collectImportGraph(projectPath);
|
|
1523
|
+
const scope = opts.files;
|
|
1524
|
+
const scopedFiles = scope ? codeFiles.filter((f) => scope.has(f)) : codeFiles;
|
|
1525
|
+
let pkg = {};
|
|
1526
|
+
try {
|
|
1527
|
+
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
const cycles = findCycles(edges).filter((c) => !scope || c.path.some((node) => scope.has(node)));
|
|
1531
|
+
const unusedFiles = scopedFiles.filter((rel) => !inDegree.has(rel) && !looksLikeEntry(rel, pkg)).sort();
|
|
1532
|
+
const IDENT_RE = /[A-Za-z_$][\w$]*/g;
|
|
1533
|
+
const identifiersByFile = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const [file, text] of fileTexts) {
|
|
1535
|
+
identifiersByFile.set(file, new Set(text.match(IDENT_RE) ?? []));
|
|
1536
|
+
}
|
|
1439
1537
|
const unusedExports = [];
|
|
1440
|
-
for (const rel of
|
|
1538
|
+
for (const rel of scopedFiles) {
|
|
1441
1539
|
for (const exp of parseExports(fileTexts.get(rel))) {
|
|
1442
1540
|
if (exp.name === "default") continue;
|
|
1443
1541
|
let used = false;
|
|
1444
|
-
for (const [otherFile,
|
|
1542
|
+
for (const [otherFile, ids] of identifiersByFile) {
|
|
1445
1543
|
if (otherFile === rel) continue;
|
|
1446
|
-
if (
|
|
1544
|
+
if (ids.has(exp.name)) {
|
|
1447
1545
|
used = true;
|
|
1448
1546
|
break;
|
|
1449
1547
|
}
|
|
@@ -1451,21 +1549,22 @@ async function analyzeProject(projectPath) {
|
|
|
1451
1549
|
if (!used) unusedExports.push({ file: rel, name: exp.name, line: exp.line });
|
|
1452
1550
|
}
|
|
1453
1551
|
}
|
|
1454
|
-
const duplicates = findDuplicates(fileTexts);
|
|
1552
|
+
const duplicates = findDuplicates(fileTexts).filter((g) => !scope || g.files.some((f) => scope.has(f)));
|
|
1455
1553
|
const hotspots = [];
|
|
1456
|
-
for (const
|
|
1457
|
-
const score2 = complexityOf(
|
|
1554
|
+
for (const rel of scopedFiles) {
|
|
1555
|
+
const score2 = complexityOf(fileTexts.get(rel));
|
|
1458
1556
|
if (score2 >= 12) hotspots.push({ file: rel, startLine: 1, score: score2 });
|
|
1459
1557
|
}
|
|
1460
1558
|
hotspots.sort((a, b) => b.score - a.score);
|
|
1461
|
-
const codeLines =
|
|
1559
|
+
const codeLines = scopedFiles.reduce((s, f) => s + fileTexts.get(f).split("\n").length, 0);
|
|
1462
1560
|
const dupLines = duplicates.reduce((s, g) => s + g.lines, 0);
|
|
1463
|
-
const
|
|
1561
|
+
const cyclesPenalty = cycles.reduce((s, c) => s + c.path.length, 0) * 2;
|
|
1562
|
+
const penalties = cyclesPenalty + unusedFiles.length * 2 + Math.min(unusedExports.length, 20) * 1 + Math.round(dupLines / Math.max(codeLines, 1) * 100) + Math.min(hotspots.length, 15) * 2;
|
|
1464
1563
|
const score = Math.max(0, Math.min(100, 100 - penalties));
|
|
1465
1564
|
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 65 ? "C" : score >= 50 ? "D" : "E";
|
|
1466
1565
|
return {
|
|
1467
1566
|
projectPath: abs,
|
|
1468
|
-
analyzedFiles:
|
|
1567
|
+
analyzedFiles: scopedFiles.length,
|
|
1469
1568
|
importEdges: edges.length,
|
|
1470
1569
|
cycles,
|
|
1471
1570
|
unusedFiles,
|
|
@@ -1507,7 +1606,7 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1507
1606
|
out.push(`${t2.score}: ${r.score}/100 (${r.grade}) \xB7 ${r.analyzedFiles} ${t2.files} \xB7 ${r.importEdges} ${t2.edges}`);
|
|
1508
1607
|
out.push("");
|
|
1509
1608
|
out.push(`\u25CF ${t2.cycles} (${r.cycles.length})`);
|
|
1510
|
-
for (const c of r.cycles.slice(0, 10)) out.push(` ${c
|
|
1609
|
+
for (const c of r.cycles.slice(0, 10)) out.push(` ${formatCycle(c)}`);
|
|
1511
1610
|
if (r.cycles.length === 0) out.push(` ${t2.none}`);
|
|
1512
1611
|
out.push("");
|
|
1513
1612
|
out.push(`\u25CF ${t2.unusedFiles} (${r.unusedFiles.length}) \u2014 ${t2.noteUnused}`);
|
|
@@ -1523,6 +1622,12 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1523
1622
|
for (const h of r.hotspots.slice(0, 10)) out.push(` ${h.file} \u2014 score ${h.score}`);
|
|
1524
1623
|
return out.join("\n");
|
|
1525
1624
|
}
|
|
1625
|
+
function formatCycle(c, md = false) {
|
|
1626
|
+
const q = (s) => md ? `\`${s}\`` : s;
|
|
1627
|
+
if (c.path.length <= 4) return c.path.map(q).join(" \u2192 ") + " \u2192 " + q(c.path[0]);
|
|
1628
|
+
const shown = c.path.slice(0, 4).map(q).join(", ");
|
|
1629
|
+
return `${c.path.length} files: ${shown}, \u2026`;
|
|
1630
|
+
}
|
|
1526
1631
|
var GRADE_ICON = { A: "\u{1F7E2}", B: "\u{1F535}", C: "\u{1F7E1}", D: "\u{1F7E0}", E: "\u{1F534}" };
|
|
1527
1632
|
function scoreBar(score) {
|
|
1528
1633
|
const filled = Math.round(score / 10);
|
|
@@ -1573,7 +1678,7 @@ function formatHealthReportMd(r, lang = "fr") {
|
|
|
1573
1678
|
out.push("");
|
|
1574
1679
|
out.push(`### ${t2.cycles} \u2014 ${r.cycles.length}`);
|
|
1575
1680
|
if (r.cycles.length === 0) out.push(t2.none);
|
|
1576
|
-
else for (const c of r.cycles.slice(0, 10)) out.push(`-
|
|
1681
|
+
else for (const c of r.cycles.slice(0, 10)) out.push(`- ${formatCycle(c, true)}`);
|
|
1577
1682
|
if (r.cycles.length > 10) out.push(t2.more(r.cycles.length - 10));
|
|
1578
1683
|
out.push("");
|
|
1579
1684
|
out.push(`### ${t2.unusedFiles} \u2014 ${r.unusedFiles.length}`);
|
|
@@ -1608,8 +1713,202 @@ function formatHealthReportMd(r, lang = "fr") {
|
|
|
1608
1713
|
} else out.push(t2.none);
|
|
1609
1714
|
return out.join("\n");
|
|
1610
1715
|
}
|
|
1716
|
+
|
|
1717
|
+
// src/impact.ts
|
|
1718
|
+
import { basename as basename2 } from "path";
|
|
1719
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1720
|
+
import { join as join7 } from "path";
|
|
1721
|
+
function matchRank(rel, q) {
|
|
1722
|
+
if (rel === q) return 0;
|
|
1723
|
+
if (rel.endsWith(`/${q}`)) return 1;
|
|
1724
|
+
if (basename2(rel) === q) return 2;
|
|
1725
|
+
if (rel.toLowerCase().includes(q.toLowerCase())) return 3;
|
|
1726
|
+
return -1;
|
|
1727
|
+
}
|
|
1728
|
+
function resolveTarget(codeFiles, query) {
|
|
1729
|
+
const q = query.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1730
|
+
if (!q) return { candidates: [] };
|
|
1731
|
+
const ranked = codeFiles.map((f) => ({ f, r: matchRank(f, q) })).filter((m) => m.r >= 0).sort((a, b) => a.r - b.r || a.f.length - b.f.length);
|
|
1732
|
+
if (!ranked.length) return { candidates: [] };
|
|
1733
|
+
const best = ranked.filter((m) => m.r === ranked[0].r);
|
|
1734
|
+
if (best.length === 1) return { target: best[0].f, candidates: [] };
|
|
1735
|
+
return { candidates: best.slice(0, 12).map((m) => m.f) };
|
|
1736
|
+
}
|
|
1737
|
+
async function analyzeImpact(projectPath, query) {
|
|
1738
|
+
const g = await collectImportGraph(projectPath);
|
|
1739
|
+
const { target, candidates } = resolveTarget(g.codeFiles, query);
|
|
1740
|
+
if (!target) return { ok: false, query, candidates };
|
|
1741
|
+
const rev = /* @__PURE__ */ new Map();
|
|
1742
|
+
for (const e of g.edges) {
|
|
1743
|
+
if (!rev.has(e.to)) rev.set(e.to, []);
|
|
1744
|
+
rev.get(e.to).push(e.from);
|
|
1745
|
+
}
|
|
1746
|
+
let inCycle = false;
|
|
1747
|
+
const queue = [target];
|
|
1748
|
+
let head = 0;
|
|
1749
|
+
const dist = /* @__PURE__ */ new Map([[target, 0]]);
|
|
1750
|
+
while (head < queue.length) {
|
|
1751
|
+
const node = queue[head++];
|
|
1752
|
+
const d = dist.get(node);
|
|
1753
|
+
for (const up of rev.get(node) ?? []) {
|
|
1754
|
+
if (up === target) {
|
|
1755
|
+
inCycle = true;
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
if (dist.has(up)) continue;
|
|
1759
|
+
dist.set(up, d + 1);
|
|
1760
|
+
queue.push(up);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
dist.delete(target);
|
|
1764
|
+
const dependents = [...dist.entries()].map(([file, d]) => ({ file, depth: d })).sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
1765
|
+
const directCount = dependents.filter((d) => d.depth === 1).length;
|
|
1766
|
+
let pkg = {};
|
|
1767
|
+
try {
|
|
1768
|
+
pkg = JSON.parse(await readFile5(join7(g.abs, "package.json"), "utf8"));
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
const isEntry = looksLikeEntry(target, pkg);
|
|
1772
|
+
const total = dependents.length;
|
|
1773
|
+
const percent = g.codeFiles.length ? total / g.codeFiles.length : 0;
|
|
1774
|
+
const risk = inCycle || percent >= 0.25 || total >= 25 ? "high" : percent >= 0.05 || total >= 5 ? "medium" : "low";
|
|
1775
|
+
return {
|
|
1776
|
+
ok: true,
|
|
1777
|
+
report: {
|
|
1778
|
+
projectPath: g.abs,
|
|
1779
|
+
query,
|
|
1780
|
+
target,
|
|
1781
|
+
exportedSymbols: parseExports(g.fileTexts.get(target) ?? ""),
|
|
1782
|
+
dependents,
|
|
1783
|
+
directCount,
|
|
1784
|
+
totalFiles: g.codeFiles.length,
|
|
1785
|
+
percent,
|
|
1786
|
+
inCycle,
|
|
1787
|
+
isEntry,
|
|
1788
|
+
risk
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
var RISK_ICON = { low: "\u{1F7E2}", medium: "\u{1F7E1}", high: "\u{1F534}" };
|
|
1793
|
+
function formatImpactReport(r, lang = "fr") {
|
|
1794
|
+
const t2 = lang === "en" ? {
|
|
1795
|
+
title: "IMPACT ANALYSIS",
|
|
1796
|
+
target: "Target",
|
|
1797
|
+
exports: "Exported symbols",
|
|
1798
|
+
direct: "Direct dependents",
|
|
1799
|
+
total: "Total blast radius",
|
|
1800
|
+
files: "code files",
|
|
1801
|
+
inCycle: "part of a dependency cycle",
|
|
1802
|
+
entry: "entry point",
|
|
1803
|
+
risk: "Risk",
|
|
1804
|
+
low: "LOW",
|
|
1805
|
+
medium: "MEDIUM",
|
|
1806
|
+
high: "HIGH",
|
|
1807
|
+
depth: "depth",
|
|
1808
|
+
none: "none \u2014 nothing imports this file",
|
|
1809
|
+
more: (n) => `\u2026and ${n} more`
|
|
1810
|
+
} : {
|
|
1811
|
+
title: "ANALYSE D\u2019IMPACT",
|
|
1812
|
+
target: "Cible",
|
|
1813
|
+
exports: "Symboles export\xE9s",
|
|
1814
|
+
direct: "D\xE9pendants directs",
|
|
1815
|
+
total: "Rayon d\u2019impact total",
|
|
1816
|
+
files: "fichiers de code",
|
|
1817
|
+
inCycle: "dans un cycle de d\xE9pendances",
|
|
1818
|
+
entry: "point d\u2019entr\xE9e",
|
|
1819
|
+
risk: "Risque",
|
|
1820
|
+
low: "FAIBLE",
|
|
1821
|
+
medium: "MOYEN",
|
|
1822
|
+
high: "\xC9LEV\xC9",
|
|
1823
|
+
depth: "profondeur",
|
|
1824
|
+
none: "aucun \u2014 rien n\u2019importe ce fichier",
|
|
1825
|
+
more: (n) => `\u2026et ${n} autres`
|
|
1826
|
+
};
|
|
1827
|
+
const tags = [r.isEntry ? t2.entry : "", r.inCycle ? t2.inCycle : ""].filter(Boolean).join(" \xB7 ");
|
|
1828
|
+
const out = [];
|
|
1829
|
+
out.push(`== ${t2.title} \u2014 ${basename2(r.projectPath)} ==`);
|
|
1830
|
+
out.push(`${t2.target}: ${r.target}${tags ? ` (${tags})` : ""}`);
|
|
1831
|
+
out.push(`${t2.exports}: ${r.exportedSymbols.length ? r.exportedSymbols.map((s) => s.name).join(", ") : "\u2014"}`);
|
|
1832
|
+
out.push(`${t2.direct}: ${r.directCount} \xB7 ${t2.total}: ${r.dependents.length} / ${r.totalFiles} ${t2.files} (${Math.round(r.percent * 100)}%)`);
|
|
1833
|
+
out.push(`${t2.risk}: ${t2[riskKey(r.risk)]}`);
|
|
1834
|
+
out.push("");
|
|
1835
|
+
if (!r.dependents.length) {
|
|
1836
|
+
out.push(` ${t2.none}`);
|
|
1837
|
+
return out.join("\n");
|
|
1838
|
+
}
|
|
1839
|
+
const byDepth = /* @__PURE__ */ new Map();
|
|
1840
|
+
for (const d of r.dependents) {
|
|
1841
|
+
if (!byDepth.has(d.depth)) byDepth.set(d.depth, []);
|
|
1842
|
+
byDepth.get(d.depth).push(d.file);
|
|
1843
|
+
}
|
|
1844
|
+
for (const [d, files] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1845
|
+
out.push(` ${t2.depth} ${d} (${files.length}):`);
|
|
1846
|
+
for (const f of files.slice(0, 12)) out.push(` ${f}`);
|
|
1847
|
+
if (files.length > 12) out.push(` ${t2.more(files.length - 12)}`);
|
|
1848
|
+
}
|
|
1849
|
+
return out.join("\n");
|
|
1850
|
+
}
|
|
1851
|
+
function riskKey(r) {
|
|
1852
|
+
return r;
|
|
1853
|
+
}
|
|
1854
|
+
function formatImpactReportMd(r, lang = "fr") {
|
|
1855
|
+
const t2 = lang === "en" ? {
|
|
1856
|
+
title: "Impact analysis",
|
|
1857
|
+
target: "Target",
|
|
1858
|
+
exports: "Exported symbols",
|
|
1859
|
+
direct: "Direct dependents",
|
|
1860
|
+
total: "Total blast radius",
|
|
1861
|
+
files: "code files",
|
|
1862
|
+
inCycle: "part of a dependency cycle",
|
|
1863
|
+
entry: "entry point",
|
|
1864
|
+
risk: "Risk",
|
|
1865
|
+
low: "LOW",
|
|
1866
|
+
medium: "MEDIUM",
|
|
1867
|
+
high: "HIGH",
|
|
1868
|
+
colDepth: "Depth",
|
|
1869
|
+
colFile: "File",
|
|
1870
|
+
none: "_None \u2014 nothing imports this file._",
|
|
1871
|
+
more: (n) => `_\u2026and ${n} more_`
|
|
1872
|
+
} : {
|
|
1873
|
+
title: "Analyse d\u2019impact",
|
|
1874
|
+
target: "Cible",
|
|
1875
|
+
exports: "Symboles export\xE9s",
|
|
1876
|
+
direct: "D\xE9pendants directs",
|
|
1877
|
+
total: "Rayon d\u2019impact total",
|
|
1878
|
+
files: "fichiers de code",
|
|
1879
|
+
inCycle: "dans un cycle de d\xE9pendances",
|
|
1880
|
+
entry: "point d\u2019entr\xE9e",
|
|
1881
|
+
risk: "Risque",
|
|
1882
|
+
low: "FAIBLE",
|
|
1883
|
+
medium: "MOYEN",
|
|
1884
|
+
high: "\xC9LEV\xC9",
|
|
1885
|
+
colDepth: "Profondeur",
|
|
1886
|
+
colFile: "Fichier",
|
|
1887
|
+
none: "_Aucun \u2014 rien n\u2019importe ce fichier._",
|
|
1888
|
+
more: (n) => `_\u2026et ${n} autres_`
|
|
1889
|
+
};
|
|
1890
|
+
const tags = [r.isEntry ? t2.entry : "", r.inCycle ? t2.inCycle : ""].filter(Boolean).join(" \xB7 ");
|
|
1891
|
+
const out = [];
|
|
1892
|
+
out.push(`## ${RISK_ICON[r.risk]} ${t2.title} \u2014 \`${basename2(r.projectPath)}\``);
|
|
1893
|
+
out.push("");
|
|
1894
|
+
out.push(`**${t2.target} : \`${r.target}\`**${tags ? ` \u2014 _${tags}_` : ""}`);
|
|
1895
|
+
out.push("");
|
|
1896
|
+
out.push(`- **${t2.exports}** : ${r.exportedSymbols.length ? r.exportedSymbols.map((s) => `\`${s.name}\``).join(", ") : "\u2014"}`);
|
|
1897
|
+
out.push(`- **${t2.direct}** : ${r.directCount} \xB7 **${t2.total}** : **${r.dependents.length}** / ${r.totalFiles} ${t2.files} (**${Math.round(r.percent * 100)}%**)`);
|
|
1898
|
+
out.push(`- **${t2.risk}** : **${t2[riskKey(r.risk)]}**`);
|
|
1899
|
+
out.push("");
|
|
1900
|
+
if (!r.dependents.length) {
|
|
1901
|
+
out.push(t2.none);
|
|
1902
|
+
return out.join("\n");
|
|
1903
|
+
}
|
|
1904
|
+
out.push(`| ${t2.colDepth} | ${t2.colFile} |`, "|---|---|");
|
|
1905
|
+
for (const d of r.dependents.slice(0, 30)) out.push(`| ${d.depth} | \`${d.file}\` |`);
|
|
1906
|
+
if (r.dependents.length > 30) out.push(`| | ${t2.more(r.dependents.length - 30)} |`);
|
|
1907
|
+
return out.join("\n");
|
|
1908
|
+
}
|
|
1611
1909
|
export {
|
|
1612
1910
|
CONFIG_FILE,
|
|
1911
|
+
analyzeImpact,
|
|
1613
1912
|
analyzeProject,
|
|
1614
1913
|
buildContext,
|
|
1615
1914
|
buildIndex,
|
|
@@ -1624,6 +1923,10 @@ export {
|
|
|
1624
1923
|
findProjectRoot,
|
|
1625
1924
|
formatHealthReport,
|
|
1626
1925
|
formatHealthReportMd,
|
|
1926
|
+
formatImpactReport,
|
|
1927
|
+
formatImpactReportMd,
|
|
1928
|
+
getCacheDir,
|
|
1929
|
+
getChangedFiles,
|
|
1627
1930
|
getEmbedding,
|
|
1628
1931
|
getEmbeddings,
|
|
1629
1932
|
getExtractor,
|