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/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
+
import { watch } from "fs";
|
|
6
|
+
import { sep as sep5 } from "path";
|
|
5
7
|
|
|
6
8
|
// src/context.ts
|
|
7
9
|
import { join as join5 } from "path";
|
|
@@ -648,7 +650,12 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
648
650
|
".vscode",
|
|
649
651
|
"__pycache__",
|
|
650
652
|
".dsh-tmp",
|
|
651
|
-
".dsh-vision-router"
|
|
653
|
+
".dsh-vision-router",
|
|
654
|
+
".agents",
|
|
655
|
+
".claude",
|
|
656
|
+
".devin",
|
|
657
|
+
".playwright-mcp",
|
|
658
|
+
".windsurf"
|
|
652
659
|
]);
|
|
653
660
|
var DEFAULT_SKIP_FILES = /* @__PURE__ */ new Set([]);
|
|
654
661
|
async function getWalkOptions(absProject) {
|
|
@@ -663,11 +670,10 @@ function projectHash(absProject) {
|
|
|
663
670
|
return createHash("sha256").update(absProject.toLowerCase()).digest("hex").slice(0, 16);
|
|
664
671
|
}
|
|
665
672
|
function resolveProjectPath(projectPath) {
|
|
666
|
-
const raw = (projectPath ?? "").trim().
|
|
667
|
-
if (raw
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
return resolve(process.cwd(), projectPath);
|
|
673
|
+
const raw = (projectPath ?? "").trim().replace(/['"]/g, "");
|
|
674
|
+
if (!raw) return process.cwd();
|
|
675
|
+
if (isAbsolute(raw)) return resolve(raw);
|
|
676
|
+
return resolve(process.cwd(), raw);
|
|
671
677
|
}
|
|
672
678
|
async function findProjectRoot(absProject) {
|
|
673
679
|
try {
|
|
@@ -1103,9 +1109,12 @@ function lexicalScore(index, query) {
|
|
|
1103
1109
|
const terms = tokenizeQuery(query);
|
|
1104
1110
|
const scores = /* @__PURE__ */ new Map();
|
|
1105
1111
|
if (terms.length === 0) return scores;
|
|
1112
|
+
const totalFiles = Math.max(Object.keys(index.files).length, 1);
|
|
1106
1113
|
for (const term of terms) {
|
|
1107
1114
|
const posting = index.terms[term];
|
|
1108
1115
|
if (!posting) continue;
|
|
1116
|
+
const df = Object.keys(posting).length;
|
|
1117
|
+
const idf = Math.log(1 + totalFiles / df);
|
|
1109
1118
|
for (const [relPath, count] of Object.entries(posting)) {
|
|
1110
1119
|
const file = index.files[relPath];
|
|
1111
1120
|
if (!file) continue;
|
|
@@ -1116,7 +1125,7 @@ function lexicalScore(index, query) {
|
|
|
1116
1125
|
if (!contentHit && !nameHit) continue;
|
|
1117
1126
|
const bonus = (nameHit ? 4 : 0) + (chunk.kind === "function" || chunk.kind === "method" ? 1 : 0) + (contentHit ? 1 : 0);
|
|
1118
1127
|
const prev = scores.get(key) ?? 0;
|
|
1119
|
-
scores.set(key, prev + count + bonus);
|
|
1128
|
+
scores.set(key, prev + count * idf + bonus);
|
|
1120
1129
|
}
|
|
1121
1130
|
}
|
|
1122
1131
|
}
|
|
@@ -1169,6 +1178,33 @@ function selectChunks(scored, maxTokens, maxChunkTokens = Infinity) {
|
|
|
1169
1178
|
return { chunks: result, tokens: used };
|
|
1170
1179
|
}
|
|
1171
1180
|
|
|
1181
|
+
// src/diff.ts
|
|
1182
|
+
import { execFile } from "child_process";
|
|
1183
|
+
import { sep as sep3 } from "path";
|
|
1184
|
+
import { promisify } from "util";
|
|
1185
|
+
var run = promisify(execFile);
|
|
1186
|
+
async function git(absProject, args) {
|
|
1187
|
+
const { stdout } = await run("git", ["-C", absProject, ...args], { maxBuffer: 16 * 1024 * 1024 });
|
|
1188
|
+
return stdout;
|
|
1189
|
+
}
|
|
1190
|
+
function toRelList(output) {
|
|
1191
|
+
return output.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => l.split(sep3).join("/"));
|
|
1192
|
+
}
|
|
1193
|
+
async function getChangedFiles(absProject, base) {
|
|
1194
|
+
try {
|
|
1195
|
+
const [tracked, untracked] = await Promise.all([
|
|
1196
|
+
// Working tree vs base — covers committed, staged and unstaged edits.
|
|
1197
|
+
git(absProject, ["diff", "--name-only", base, "--"]),
|
|
1198
|
+
git(absProject, ["ls-files", "--others", "--exclude-standard"])
|
|
1199
|
+
]);
|
|
1200
|
+
const files = /* @__PURE__ */ new Set([...toRelList(tracked), ...toRelList(untracked)]);
|
|
1201
|
+
return { files, ok: true };
|
|
1202
|
+
} catch (err) {
|
|
1203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1204
|
+
return { files: /* @__PURE__ */ new Set(), ok: false, error: msg.split("\n")[0] };
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1172
1208
|
// src/context.ts
|
|
1173
1209
|
var DEFAULT_MAX_TOKENS = 6e4;
|
|
1174
1210
|
var HEAD_BUDGET_TOKENS = 800;
|
|
@@ -1179,23 +1215,38 @@ function getLabels(lang) {
|
|
|
1179
1215
|
tree: "File tree",
|
|
1180
1216
|
noConstraints: "No explicit constraints documented.",
|
|
1181
1217
|
constraints: "IDENTIFIED PRODUCT CONSTRAINTS",
|
|
1182
|
-
answerIn: "Answer in English."
|
|
1218
|
+
answerIn: "Answer in English.",
|
|
1219
|
+
diffScope: (base, n) => `Scope: ${n} file(s) changed vs ${base}`,
|
|
1220
|
+
diffUnavailable: (base) => `Scope: diff vs ${base} unavailable (not a git repo?) \u2014 full project`
|
|
1183
1221
|
} : {
|
|
1184
1222
|
project: "Projet",
|
|
1185
1223
|
focus: "Focus",
|
|
1186
1224
|
tree: "Arborescence",
|
|
1187
1225
|
noConstraints: "Aucune contrainte explicite document\xE9e.",
|
|
1188
1226
|
constraints: "CONTRAINTES PRODUIT IDENTIFI\xC9ES",
|
|
1189
|
-
answerIn: "R\xE9ponds obligatoirement en fran\xE7ais."
|
|
1227
|
+
answerIn: "R\xE9ponds obligatoirement en fran\xE7ais.",
|
|
1228
|
+
diffScope: (base, n) => `P\xE9rim\xE8tre : ${n} fichier(s) modifi\xE9(s) vs ${base}`,
|
|
1229
|
+
diffUnavailable: (base) => `P\xE9rim\xE8tre : diff vs ${base} indisponible (pas un repo git ?) \u2014 projet complet`
|
|
1190
1230
|
};
|
|
1191
1231
|
}
|
|
1192
1232
|
async function extractProductConstraints(absProject) {
|
|
1193
|
-
const candidates = [
|
|
1233
|
+
const candidates = [
|
|
1234
|
+
"README.md",
|
|
1235
|
+
"README.MD",
|
|
1236
|
+
"readme.md",
|
|
1237
|
+
"MEMORY.md",
|
|
1238
|
+
"CONTRIBUTING.md",
|
|
1239
|
+
"AGENTS.md",
|
|
1240
|
+
"CLAUDE.md",
|
|
1241
|
+
".windsurfrules",
|
|
1242
|
+
".cursorrules",
|
|
1243
|
+
".cursorrules.md"
|
|
1244
|
+
];
|
|
1194
1245
|
const constraints = [];
|
|
1195
1246
|
for (const name of candidates) {
|
|
1196
1247
|
const text = await safeReadText(join5(absProject, name));
|
|
1197
1248
|
if (!text) continue;
|
|
1198
|
-
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation)[\s\S]{0,200}/gi;
|
|
1249
|
+
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation|never|always|jamais|toujours|zéro|zero)[\s\S]{0,200}/gi;
|
|
1199
1250
|
let m;
|
|
1200
1251
|
while ((m = regex.exec(text)) !== null) {
|
|
1201
1252
|
const line = m[0].replace(/\s+/g, " ").trim();
|
|
@@ -1213,13 +1264,30 @@ function formatChunk(chunk) {
|
|
|
1213
1264
|
${chunk.content}`;
|
|
1214
1265
|
}
|
|
1215
1266
|
async function buildContext(options) {
|
|
1216
|
-
const { project, query, filePath, searchQuery, instruction, embed = false } = options;
|
|
1267
|
+
const { project, query, filePath, searchQuery, instruction, embed = false, diff } = options;
|
|
1217
1268
|
const absProject = await findProjectRoot(resolveProjectPath(project));
|
|
1218
1269
|
const cfg = await loadProjectConfig(absProject);
|
|
1219
1270
|
const maxTokens = options.maxTokens ?? cfg.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
1220
1271
|
const lang = options.lang ?? cfg.lang ?? "fr";
|
|
1221
1272
|
const labels = getLabels(lang);
|
|
1222
1273
|
const index = await getIndex(absProject);
|
|
1274
|
+
let scopedIndex = index;
|
|
1275
|
+
let diffFiles;
|
|
1276
|
+
let scopeLine = "";
|
|
1277
|
+
if (diff && !filePath) {
|
|
1278
|
+
const scope = await getChangedFiles(absProject, diff);
|
|
1279
|
+
if (scope.ok) {
|
|
1280
|
+
diffFiles = [...scope.files].filter((f) => index.files[f]);
|
|
1281
|
+
const files = {};
|
|
1282
|
+
for (const f of diffFiles) files[f] = index.files[f];
|
|
1283
|
+
scopedIndex = { ...index, files };
|
|
1284
|
+
scopeLine = `${labels.diffScope(diff, diffFiles.length)}
|
|
1285
|
+
`;
|
|
1286
|
+
} else {
|
|
1287
|
+
scopeLine = `${labels.diffUnavailable(diff)}
|
|
1288
|
+
`;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1223
1291
|
if (embed) {
|
|
1224
1292
|
try {
|
|
1225
1293
|
await embedIndex(index);
|
|
@@ -1241,17 +1309,17 @@ async function buildContext(options) {
|
|
|
1241
1309
|
);
|
|
1242
1310
|
selectedChunks = chunks;
|
|
1243
1311
|
} else {
|
|
1244
|
-
const scored = await scoreChunks(
|
|
1312
|
+
const scored = await scoreChunks(scopedIndex, filePath, embed);
|
|
1245
1313
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1246
1314
|
if (chunks.length === 0) throw new Error(`File not found: ${filePath}`);
|
|
1247
1315
|
selectedChunks = chunks;
|
|
1248
1316
|
}
|
|
1249
1317
|
} else if (searchQuery) {
|
|
1250
|
-
const scored = await scoreChunks(
|
|
1318
|
+
const scored = await scoreChunks(scopedIndex, searchQuery, embed);
|
|
1251
1319
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1252
1320
|
selectedChunks = chunks;
|
|
1253
1321
|
} else {
|
|
1254
|
-
const scored = await scoreChunks(
|
|
1322
|
+
const scored = await scoreChunks(scopedIndex, query, embed);
|
|
1255
1323
|
const { chunks } = selectChunks(scored, bodyLimit, maxChunkTokens);
|
|
1256
1324
|
selectedChunks = chunks;
|
|
1257
1325
|
}
|
|
@@ -1261,7 +1329,7 @@ ${constraints.map((c) => `- ${c}`).join("\n")}` : `== ${labels.constraints} ==
|
|
|
1261
1329
|
${labels.noConstraints}`;
|
|
1262
1330
|
const head = `${labels.project} : ${absProject}
|
|
1263
1331
|
${labels.focus} : ${focus}
|
|
1264
|
-
${constraintsText}
|
|
1332
|
+
${scopeLine}${constraintsText}
|
|
1265
1333
|
|
|
1266
1334
|
== ${labels.tree} ==
|
|
1267
1335
|
${index.tree}
|
|
@@ -1285,12 +1353,13 @@ ${finalInstruction}`;
|
|
|
1285
1353
|
absProject,
|
|
1286
1354
|
context: prompt,
|
|
1287
1355
|
chunks: selectedChunks,
|
|
1288
|
-
tokenCount
|
|
1356
|
+
tokenCount,
|
|
1357
|
+
diffFiles
|
|
1289
1358
|
};
|
|
1290
1359
|
}
|
|
1291
1360
|
|
|
1292
1361
|
// src/analysis.ts
|
|
1293
|
-
import { basename, extname as extname2, join as join6, relative as relative3, sep as
|
|
1362
|
+
import { basename, extname as extname2, join as join6, relative as relative3, sep as sep4, posix as posixPath } from "path";
|
|
1294
1363
|
import { readFile as readFile4 } from "fs/promises";
|
|
1295
1364
|
var CODE_EXTS = /* @__PURE__ */ new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
|
1296
1365
|
var ENTRY_BASENAMES = /* @__PURE__ */ new Set(["index", "main", "app", "cli", "server", "bin", "mod"]);
|
|
@@ -1333,36 +1402,58 @@ function parseExports(text) {
|
|
|
1333
1402
|
}
|
|
1334
1403
|
function findCycles(edges) {
|
|
1335
1404
|
const adj = /* @__PURE__ */ new Map();
|
|
1405
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
1336
1406
|
for (const e of edges) {
|
|
1407
|
+
nodes.add(e.from);
|
|
1408
|
+
nodes.add(e.to);
|
|
1337
1409
|
if (!adj.has(e.from)) adj.set(e.from, []);
|
|
1338
1410
|
adj.get(e.from).push(e.to);
|
|
1339
1411
|
}
|
|
1340
|
-
const
|
|
1341
|
-
const
|
|
1342
|
-
const stack = [];
|
|
1412
|
+
const index = /* @__PURE__ */ new Map();
|
|
1413
|
+
const low = /* @__PURE__ */ new Map();
|
|
1343
1414
|
const onStack = /* @__PURE__ */ new Set();
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1415
|
+
const stack = [];
|
|
1416
|
+
const sccs = [];
|
|
1417
|
+
let counter = 0;
|
|
1418
|
+
for (const root of nodes) {
|
|
1419
|
+
if (index.has(root)) continue;
|
|
1420
|
+
const work = [[root, 0]];
|
|
1421
|
+
while (work.length) {
|
|
1422
|
+
const top = work[work.length - 1];
|
|
1423
|
+
const [v, ci] = top;
|
|
1424
|
+
if (ci === 0) {
|
|
1425
|
+
index.set(v, counter);
|
|
1426
|
+
low.set(v, counter);
|
|
1427
|
+
counter++;
|
|
1428
|
+
stack.push(v);
|
|
1429
|
+
onStack.add(v);
|
|
1430
|
+
}
|
|
1431
|
+
const children = adj.get(v) ?? [];
|
|
1432
|
+
if (ci < children.length) {
|
|
1433
|
+
top[1] = ci + 1;
|
|
1434
|
+
const w = children[ci];
|
|
1435
|
+
if (!index.has(w)) work.push([w, 0]);
|
|
1436
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
1437
|
+
} else {
|
|
1438
|
+
work.pop();
|
|
1439
|
+
if (work.length) {
|
|
1440
|
+
const parent = work[work.length - 1][0];
|
|
1441
|
+
low.set(parent, Math.min(low.get(parent), low.get(v)));
|
|
1442
|
+
}
|
|
1443
|
+
if (low.get(v) === index.get(v)) {
|
|
1444
|
+
const scc = [];
|
|
1445
|
+
let w;
|
|
1446
|
+
do {
|
|
1447
|
+
w = stack.pop();
|
|
1448
|
+
onStack.delete(w);
|
|
1449
|
+
scc.push(w);
|
|
1450
|
+
} while (w !== v);
|
|
1451
|
+
if (scc.length > 1 || (adj.get(v) ?? []).includes(v)) sccs.push(scc);
|
|
1356
1452
|
}
|
|
1357
|
-
} else if (!stack.includes(next)) {
|
|
1358
|
-
dfs(next);
|
|
1359
1453
|
}
|
|
1360
1454
|
}
|
|
1361
|
-
stack.pop();
|
|
1362
|
-
onStack.delete(node);
|
|
1363
1455
|
}
|
|
1364
|
-
|
|
1365
|
-
return cycles;
|
|
1456
|
+
return sccs.map((m) => ({ path: m.sort() })).sort((a, b) => b.path.length - a.path.length);
|
|
1366
1457
|
}
|
|
1367
1458
|
function looksLikeEntry(rel, pkg) {
|
|
1368
1459
|
const base = basename(rel).toLowerCase().replace(extname2(rel), "");
|
|
@@ -1405,13 +1496,13 @@ function findDuplicates(fileTexts) {
|
|
|
1405
1496
|
}
|
|
1406
1497
|
return [...groups.values()].sort((a, b) => b.lines - a.lines).slice(0, 15);
|
|
1407
1498
|
}
|
|
1408
|
-
async function
|
|
1499
|
+
async function collectImportGraph(projectPath) {
|
|
1409
1500
|
const abs = await findProjectRoot(resolveProjectPath(projectPath));
|
|
1410
1501
|
const fileTexts = /* @__PURE__ */ new Map();
|
|
1411
1502
|
const codeFiles = [];
|
|
1412
1503
|
const walk = await getWalkOptions(abs);
|
|
1413
1504
|
for await (const full of walkFiles(abs, walk.skipDirs, walk.skipFiles, walk.ignoreGlobs)) {
|
|
1414
|
-
const rel = relative3(abs, full).split(
|
|
1505
|
+
const rel = relative3(abs, full).split(sep4).join("/");
|
|
1415
1506
|
const ext = extname2(rel).toLowerCase();
|
|
1416
1507
|
if (!CODE_EXTS.has(ext) || SKIP_EXTS.has(ext) || rel.includes(".min.")) continue;
|
|
1417
1508
|
const text = await safeReadText(full);
|
|
@@ -1419,11 +1510,6 @@ async function analyzeProject(projectPath) {
|
|
|
1419
1510
|
codeFiles.push(rel);
|
|
1420
1511
|
fileTexts.set(rel, text);
|
|
1421
1512
|
}
|
|
1422
|
-
let pkg = {};
|
|
1423
|
-
try {
|
|
1424
|
-
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1425
|
-
} catch {
|
|
1426
|
-
}
|
|
1427
1513
|
const known = new Set(codeFiles);
|
|
1428
1514
|
const edges = [];
|
|
1429
1515
|
const inDegree = /* @__PURE__ */ new Map();
|
|
@@ -1433,18 +1519,32 @@ async function analyzeProject(projectPath) {
|
|
|
1433
1519
|
inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
|
|
1434
1520
|
}
|
|
1435
1521
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1522
|
+
return { abs, codeFiles, fileTexts, edges, inDegree };
|
|
1523
|
+
}
|
|
1524
|
+
async function analyzeProject(projectPath, opts = {}) {
|
|
1525
|
+
const { abs, codeFiles, fileTexts, edges, inDegree } = await collectImportGraph(projectPath);
|
|
1526
|
+
const scope = opts.files;
|
|
1527
|
+
const scopedFiles = scope ? codeFiles.filter((f) => scope.has(f)) : codeFiles;
|
|
1528
|
+
let pkg = {};
|
|
1529
|
+
try {
|
|
1530
|
+
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1531
|
+
} catch {
|
|
1532
|
+
}
|
|
1533
|
+
const cycles = findCycles(edges).filter((c) => !scope || c.path.some((node) => scope.has(node)));
|
|
1534
|
+
const unusedFiles = scopedFiles.filter((rel) => !inDegree.has(rel) && !looksLikeEntry(rel, pkg)).sort();
|
|
1535
|
+
const IDENT_RE = /[A-Za-z_$][\w$]*/g;
|
|
1536
|
+
const identifiersByFile = /* @__PURE__ */ new Map();
|
|
1537
|
+
for (const [file, text] of fileTexts) {
|
|
1538
|
+
identifiersByFile.set(file, new Set(text.match(IDENT_RE) ?? []));
|
|
1539
|
+
}
|
|
1440
1540
|
const unusedExports = [];
|
|
1441
|
-
for (const rel of
|
|
1541
|
+
for (const rel of scopedFiles) {
|
|
1442
1542
|
for (const exp of parseExports(fileTexts.get(rel))) {
|
|
1443
1543
|
if (exp.name === "default") continue;
|
|
1444
1544
|
let used = false;
|
|
1445
|
-
for (const [otherFile,
|
|
1545
|
+
for (const [otherFile, ids] of identifiersByFile) {
|
|
1446
1546
|
if (otherFile === rel) continue;
|
|
1447
|
-
if (
|
|
1547
|
+
if (ids.has(exp.name)) {
|
|
1448
1548
|
used = true;
|
|
1449
1549
|
break;
|
|
1450
1550
|
}
|
|
@@ -1452,21 +1552,22 @@ async function analyzeProject(projectPath) {
|
|
|
1452
1552
|
if (!used) unusedExports.push({ file: rel, name: exp.name, line: exp.line });
|
|
1453
1553
|
}
|
|
1454
1554
|
}
|
|
1455
|
-
const duplicates = findDuplicates(fileTexts);
|
|
1555
|
+
const duplicates = findDuplicates(fileTexts).filter((g) => !scope || g.files.some((f) => scope.has(f)));
|
|
1456
1556
|
const hotspots = [];
|
|
1457
|
-
for (const
|
|
1458
|
-
const score2 = complexityOf(
|
|
1557
|
+
for (const rel of scopedFiles) {
|
|
1558
|
+
const score2 = complexityOf(fileTexts.get(rel));
|
|
1459
1559
|
if (score2 >= 12) hotspots.push({ file: rel, startLine: 1, score: score2 });
|
|
1460
1560
|
}
|
|
1461
1561
|
hotspots.sort((a, b) => b.score - a.score);
|
|
1462
|
-
const codeLines =
|
|
1562
|
+
const codeLines = scopedFiles.reduce((s, f) => s + fileTexts.get(f).split("\n").length, 0);
|
|
1463
1563
|
const dupLines = duplicates.reduce((s, g) => s + g.lines, 0);
|
|
1464
|
-
const
|
|
1564
|
+
const cyclesPenalty = cycles.reduce((s, c) => s + c.path.length, 0) * 2;
|
|
1565
|
+
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;
|
|
1465
1566
|
const score = Math.max(0, Math.min(100, 100 - penalties));
|
|
1466
1567
|
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 65 ? "C" : score >= 50 ? "D" : "E";
|
|
1467
1568
|
return {
|
|
1468
1569
|
projectPath: abs,
|
|
1469
|
-
analyzedFiles:
|
|
1570
|
+
analyzedFiles: scopedFiles.length,
|
|
1470
1571
|
importEdges: edges.length,
|
|
1471
1572
|
cycles,
|
|
1472
1573
|
unusedFiles,
|
|
@@ -1508,7 +1609,7 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1508
1609
|
out.push(`${t2.score}: ${r.score}/100 (${r.grade}) \xB7 ${r.analyzedFiles} ${t2.files} \xB7 ${r.importEdges} ${t2.edges}`);
|
|
1509
1610
|
out.push("");
|
|
1510
1611
|
out.push(`\u25CF ${t2.cycles} (${r.cycles.length})`);
|
|
1511
|
-
for (const c of r.cycles.slice(0, 10)) out.push(` ${c
|
|
1612
|
+
for (const c of r.cycles.slice(0, 10)) out.push(` ${formatCycle(c)}`);
|
|
1512
1613
|
if (r.cycles.length === 0) out.push(` ${t2.none}`);
|
|
1513
1614
|
out.push("");
|
|
1514
1615
|
out.push(`\u25CF ${t2.unusedFiles} (${r.unusedFiles.length}) \u2014 ${t2.noteUnused}`);
|
|
@@ -1524,6 +1625,149 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1524
1625
|
for (const h of r.hotspots.slice(0, 10)) out.push(` ${h.file} \u2014 score ${h.score}`);
|
|
1525
1626
|
return out.join("\n");
|
|
1526
1627
|
}
|
|
1628
|
+
function formatCycle(c, md = false) {
|
|
1629
|
+
const q = (s) => md ? `\`${s}\`` : s;
|
|
1630
|
+
if (c.path.length <= 4) return c.path.map(q).join(" \u2192 ") + " \u2192 " + q(c.path[0]);
|
|
1631
|
+
const shown = c.path.slice(0, 4).map(q).join(", ");
|
|
1632
|
+
return `${c.path.length} files: ${shown}, \u2026`;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// src/impact.ts
|
|
1636
|
+
import { basename as basename2 } from "path";
|
|
1637
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1638
|
+
import { join as join7 } from "path";
|
|
1639
|
+
function matchRank(rel, q) {
|
|
1640
|
+
if (rel === q) return 0;
|
|
1641
|
+
if (rel.endsWith(`/${q}`)) return 1;
|
|
1642
|
+
if (basename2(rel) === q) return 2;
|
|
1643
|
+
if (rel.toLowerCase().includes(q.toLowerCase())) return 3;
|
|
1644
|
+
return -1;
|
|
1645
|
+
}
|
|
1646
|
+
function resolveTarget(codeFiles, query) {
|
|
1647
|
+
const q = query.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1648
|
+
if (!q) return { candidates: [] };
|
|
1649
|
+
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);
|
|
1650
|
+
if (!ranked.length) return { candidates: [] };
|
|
1651
|
+
const best = ranked.filter((m) => m.r === ranked[0].r);
|
|
1652
|
+
if (best.length === 1) return { target: best[0].f, candidates: [] };
|
|
1653
|
+
return { candidates: best.slice(0, 12).map((m) => m.f) };
|
|
1654
|
+
}
|
|
1655
|
+
async function analyzeImpact(projectPath, query) {
|
|
1656
|
+
const g = await collectImportGraph(projectPath);
|
|
1657
|
+
const { target, candidates } = resolveTarget(g.codeFiles, query);
|
|
1658
|
+
if (!target) return { ok: false, query, candidates };
|
|
1659
|
+
const rev = /* @__PURE__ */ new Map();
|
|
1660
|
+
for (const e of g.edges) {
|
|
1661
|
+
if (!rev.has(e.to)) rev.set(e.to, []);
|
|
1662
|
+
rev.get(e.to).push(e.from);
|
|
1663
|
+
}
|
|
1664
|
+
let inCycle = false;
|
|
1665
|
+
const queue = [target];
|
|
1666
|
+
let head = 0;
|
|
1667
|
+
const dist = /* @__PURE__ */ new Map([[target, 0]]);
|
|
1668
|
+
while (head < queue.length) {
|
|
1669
|
+
const node = queue[head++];
|
|
1670
|
+
const d = dist.get(node);
|
|
1671
|
+
for (const up of rev.get(node) ?? []) {
|
|
1672
|
+
if (up === target) {
|
|
1673
|
+
inCycle = true;
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
if (dist.has(up)) continue;
|
|
1677
|
+
dist.set(up, d + 1);
|
|
1678
|
+
queue.push(up);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
dist.delete(target);
|
|
1682
|
+
const dependents = [...dist.entries()].map(([file, d]) => ({ file, depth: d })).sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
1683
|
+
const directCount = dependents.filter((d) => d.depth === 1).length;
|
|
1684
|
+
let pkg = {};
|
|
1685
|
+
try {
|
|
1686
|
+
pkg = JSON.parse(await readFile5(join7(g.abs, "package.json"), "utf8"));
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1689
|
+
const isEntry = looksLikeEntry(target, pkg);
|
|
1690
|
+
const total = dependents.length;
|
|
1691
|
+
const percent = g.codeFiles.length ? total / g.codeFiles.length : 0;
|
|
1692
|
+
const risk = inCycle || percent >= 0.25 || total >= 25 ? "high" : percent >= 0.05 || total >= 5 ? "medium" : "low";
|
|
1693
|
+
return {
|
|
1694
|
+
ok: true,
|
|
1695
|
+
report: {
|
|
1696
|
+
projectPath: g.abs,
|
|
1697
|
+
query,
|
|
1698
|
+
target,
|
|
1699
|
+
exportedSymbols: parseExports(g.fileTexts.get(target) ?? ""),
|
|
1700
|
+
dependents,
|
|
1701
|
+
directCount,
|
|
1702
|
+
totalFiles: g.codeFiles.length,
|
|
1703
|
+
percent,
|
|
1704
|
+
inCycle,
|
|
1705
|
+
isEntry,
|
|
1706
|
+
risk
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
function formatImpactReport(r, lang = "fr") {
|
|
1711
|
+
const t2 = lang === "en" ? {
|
|
1712
|
+
title: "IMPACT ANALYSIS",
|
|
1713
|
+
target: "Target",
|
|
1714
|
+
exports: "Exported symbols",
|
|
1715
|
+
direct: "Direct dependents",
|
|
1716
|
+
total: "Total blast radius",
|
|
1717
|
+
files: "code files",
|
|
1718
|
+
inCycle: "part of a dependency cycle",
|
|
1719
|
+
entry: "entry point",
|
|
1720
|
+
risk: "Risk",
|
|
1721
|
+
low: "LOW",
|
|
1722
|
+
medium: "MEDIUM",
|
|
1723
|
+
high: "HIGH",
|
|
1724
|
+
depth: "depth",
|
|
1725
|
+
none: "none \u2014 nothing imports this file",
|
|
1726
|
+
more: (n) => `\u2026and ${n} more`
|
|
1727
|
+
} : {
|
|
1728
|
+
title: "ANALYSE D\u2019IMPACT",
|
|
1729
|
+
target: "Cible",
|
|
1730
|
+
exports: "Symboles export\xE9s",
|
|
1731
|
+
direct: "D\xE9pendants directs",
|
|
1732
|
+
total: "Rayon d\u2019impact total",
|
|
1733
|
+
files: "fichiers de code",
|
|
1734
|
+
inCycle: "dans un cycle de d\xE9pendances",
|
|
1735
|
+
entry: "point d\u2019entr\xE9e",
|
|
1736
|
+
risk: "Risque",
|
|
1737
|
+
low: "FAIBLE",
|
|
1738
|
+
medium: "MOYEN",
|
|
1739
|
+
high: "\xC9LEV\xC9",
|
|
1740
|
+
depth: "profondeur",
|
|
1741
|
+
none: "aucun \u2014 rien n\u2019importe ce fichier",
|
|
1742
|
+
more: (n) => `\u2026et ${n} autres`
|
|
1743
|
+
};
|
|
1744
|
+
const tags = [r.isEntry ? t2.entry : "", r.inCycle ? t2.inCycle : ""].filter(Boolean).join(" \xB7 ");
|
|
1745
|
+
const out = [];
|
|
1746
|
+
out.push(`== ${t2.title} \u2014 ${basename2(r.projectPath)} ==`);
|
|
1747
|
+
out.push(`${t2.target}: ${r.target}${tags ? ` (${tags})` : ""}`);
|
|
1748
|
+
out.push(`${t2.exports}: ${r.exportedSymbols.length ? r.exportedSymbols.map((s) => s.name).join(", ") : "\u2014"}`);
|
|
1749
|
+
out.push(`${t2.direct}: ${r.directCount} \xB7 ${t2.total}: ${r.dependents.length} / ${r.totalFiles} ${t2.files} (${Math.round(r.percent * 100)}%)`);
|
|
1750
|
+
out.push(`${t2.risk}: ${t2[riskKey(r.risk)]}`);
|
|
1751
|
+
out.push("");
|
|
1752
|
+
if (!r.dependents.length) {
|
|
1753
|
+
out.push(` ${t2.none}`);
|
|
1754
|
+
return out.join("\n");
|
|
1755
|
+
}
|
|
1756
|
+
const byDepth = /* @__PURE__ */ new Map();
|
|
1757
|
+
for (const d of r.dependents) {
|
|
1758
|
+
if (!byDepth.has(d.depth)) byDepth.set(d.depth, []);
|
|
1759
|
+
byDepth.get(d.depth).push(d.file);
|
|
1760
|
+
}
|
|
1761
|
+
for (const [d, files] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1762
|
+
out.push(` ${t2.depth} ${d} (${files.length}):`);
|
|
1763
|
+
for (const f of files.slice(0, 12)) out.push(` ${f}`);
|
|
1764
|
+
if (files.length > 12) out.push(` ${t2.more(files.length - 12)}`);
|
|
1765
|
+
}
|
|
1766
|
+
return out.join("\n");
|
|
1767
|
+
}
|
|
1768
|
+
function riskKey(r) {
|
|
1769
|
+
return r;
|
|
1770
|
+
}
|
|
1527
1771
|
|
|
1528
1772
|
// src/cli.ts
|
|
1529
1773
|
var ExitSignal = class {
|
|
@@ -1548,6 +1792,9 @@ Usage:
|
|
|
1548
1792
|
npx dsh-codebase-chat --project <path> --index
|
|
1549
1793
|
npx dsh-codebase-chat --project <path> --stats
|
|
1550
1794
|
npx dsh-codebase-chat --project <path> --health
|
|
1795
|
+
npx dsh-codebase-chat --project <path> --impact src/store.ts
|
|
1796
|
+
npx dsh-codebase-chat --project <path> --health --diff main
|
|
1797
|
+
npx dsh-codebase-chat --project <path> --watch
|
|
1551
1798
|
|
|
1552
1799
|
Options:
|
|
1553
1800
|
-p, --project <path> Project directory (default: current directory)
|
|
@@ -1557,6 +1804,9 @@ Options:
|
|
|
1557
1804
|
-i, --index Force re-index the project
|
|
1558
1805
|
-t, --stats Print indexing stats
|
|
1559
1806
|
-H, --health Deterministic static analysis (cycles, dead code, dupes, complexity)
|
|
1807
|
+
--impact <file> Blast radius \u2014 which files transitively depend on <file>
|
|
1808
|
+
-d, --diff <ref> Scope --ask/--search/--health to files changed vs a git ref
|
|
1809
|
+
-w, --watch Keep the index hot \u2014 rebuild incrementally on file changes
|
|
1560
1810
|
-e, --embed Enable local semantic embeddings (slower, more relevant)
|
|
1561
1811
|
--lang <en|fr> Language for headings (default: .codebase-chat.json lang, else fr)
|
|
1562
1812
|
-h, --help Show this help
|
|
@@ -1579,6 +1829,9 @@ async function main() {
|
|
|
1579
1829
|
index: { type: "boolean", short: "i", default: false },
|
|
1580
1830
|
stats: { type: "boolean", short: "t", default: false },
|
|
1581
1831
|
health: { type: "boolean", short: "H", default: false },
|
|
1832
|
+
impact: { type: "string" },
|
|
1833
|
+
diff: { type: "string", short: "d" },
|
|
1834
|
+
watch: { type: "boolean", short: "w", default: false },
|
|
1582
1835
|
embed: { type: "boolean", short: "e", default: false },
|
|
1583
1836
|
lang: { type: "string" },
|
|
1584
1837
|
help: { type: "boolean", short: "h", default: false }
|
|
@@ -1608,11 +1861,64 @@ async function main() {
|
|
|
1608
1861
|
console.log(`Cache: ${index.projectHash}`);
|
|
1609
1862
|
exit(0);
|
|
1610
1863
|
}
|
|
1864
|
+
if (values.watch) {
|
|
1865
|
+
const abs = await findProjectRoot(project);
|
|
1866
|
+
const walk = await getWalkOptions(abs);
|
|
1867
|
+
await getIndex(abs, (m) => console.log(m), true);
|
|
1868
|
+
console.log(lang === "en" ? "Watch mode \u2014 the index stays hot while you code. (Ctrl+C to quit)" : "Mode watch \u2014 l\u2019index reste \xE0 jour pendant que tu codes. (Ctrl+C pour quitter)");
|
|
1869
|
+
let timer;
|
|
1870
|
+
let pending = /* @__PURE__ */ new Set();
|
|
1871
|
+
const watcher = watch(abs, { recursive: true }, (_event, filename) => {
|
|
1872
|
+
if (!filename) return;
|
|
1873
|
+
const rel = filename.split(sep5).join("/");
|
|
1874
|
+
if (rel.split("/").some((p) => walk.skipDirs.has(p))) return;
|
|
1875
|
+
pending.add(rel);
|
|
1876
|
+
if (timer) clearTimeout(timer);
|
|
1877
|
+
timer = setTimeout(() => {
|
|
1878
|
+
const changed = [...pending];
|
|
1879
|
+
pending = /* @__PURE__ */ new Set();
|
|
1880
|
+
const t0 = Date.now();
|
|
1881
|
+
void getIndex(abs, () => {
|
|
1882
|
+
}).then(() => {
|
|
1883
|
+
const dt = ((Date.now() - t0) / 1e3).toFixed(1);
|
|
1884
|
+
const list = changed.slice(0, 4).join(", ") + (changed.length > 4 ? ` +${changed.length - 4}` : "");
|
|
1885
|
+
console.log(lang === "en" ? `Changed: ${list} \u2192 index refreshed (${dt}s)` : `Modifi\xE9 : ${list} \u2192 index \xE0 jour (${dt}s)`);
|
|
1886
|
+
});
|
|
1887
|
+
}, 500);
|
|
1888
|
+
});
|
|
1889
|
+
process.on("SIGINT", () => {
|
|
1890
|
+
watcher.close();
|
|
1891
|
+
exit(0);
|
|
1892
|
+
});
|
|
1893
|
+
await new Promise(() => {
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1611
1896
|
if (values.health) {
|
|
1612
|
-
|
|
1897
|
+
let scope;
|
|
1898
|
+
if (values.diff) {
|
|
1899
|
+
const s = await getChangedFiles(await findProjectRoot(project), values.diff);
|
|
1900
|
+
if (s.ok) {
|
|
1901
|
+
scope = { files: s.files };
|
|
1902
|
+
console.log(lang === "en" ? `Diff scope: ${s.files.size} file(s) changed vs ${values.diff}` : `P\xE9rim\xE8tre diff : ${s.files.size} fichier(s) modifi\xE9(s) vs ${values.diff}`);
|
|
1903
|
+
} else {
|
|
1904
|
+
console.error(lang === "en" ? `warning: diff vs "${values.diff}" unavailable (${s.error}) \u2014 full project` : `attention : diff vs "${values.diff}" indisponible (${s.error}) \u2014 projet complet`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
const report = await analyzeProject(project, scope);
|
|
1613
1908
|
console.log(formatHealthReport(report, lang));
|
|
1614
1909
|
exit(0);
|
|
1615
1910
|
}
|
|
1911
|
+
if (values.impact) {
|
|
1912
|
+
const r = await analyzeImpact(project, values.impact);
|
|
1913
|
+
if (!r.ok) {
|
|
1914
|
+
console.log(lang === "en" ? r.candidates.length ? `Ambiguous target "${values.impact}" \u2014 candidates:
|
|
1915
|
+
${r.candidates.join("\n ")}` : `No code file matches "${values.impact}".` : r.candidates.length ? `Cible ambigu\xEB "${values.impact}" \u2014 candidats :
|
|
1916
|
+
${r.candidates.join("\n ")}` : `Aucun fichier de code ne correspond \xE0 "${values.impact}".`);
|
|
1917
|
+
exit(1);
|
|
1918
|
+
}
|
|
1919
|
+
console.log(formatImpactReport(r.report, lang));
|
|
1920
|
+
exit(0);
|
|
1921
|
+
}
|
|
1616
1922
|
if (values.ask || values.search || values.file) {
|
|
1617
1923
|
const result = await buildContext({
|
|
1618
1924
|
project: values.project,
|
|
@@ -1620,7 +1926,8 @@ async function main() {
|
|
|
1620
1926
|
searchQuery: values.search,
|
|
1621
1927
|
filePath: values.file,
|
|
1622
1928
|
lang,
|
|
1623
|
-
embed: values.embed
|
|
1929
|
+
embed: values.embed,
|
|
1930
|
+
diff: values.diff
|
|
1624
1931
|
});
|
|
1625
1932
|
console.log(result.context);
|
|
1626
1933
|
console.log(`
|