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/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().toLowerCase().replace(/['"]/g, "");
667
- if (raw === "dako") return "D:\\Nouveau dossier";
668
- if (!projectPath) return process.cwd();
669
- if (isAbsolute(projectPath)) return resolve(projectPath);
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 = ["README.md", "README.MD", "readme.md", "MEMORY.md", "CONTRIBUTING.md"];
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(index, filePath, embed);
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(index, searchQuery, embed);
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(index, query, embed);
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 sep3, posix as posixPath } from "path";
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 cycles = [];
1341
- const seen = /* @__PURE__ */ new Set();
1342
- const stack = [];
1412
+ const index = /* @__PURE__ */ new Map();
1413
+ const low = /* @__PURE__ */ new Map();
1343
1414
  const onStack = /* @__PURE__ */ new Set();
1344
- function dfs(node) {
1345
- stack.push(node);
1346
- onStack.add(node);
1347
- for (const next of adj.get(node) ?? []) {
1348
- if (onStack.has(next)) {
1349
- const cycle = stack.slice(stack.indexOf(next)).concat(next);
1350
- const body = cycle.slice(0, -1);
1351
- const minIdx = body.indexOf(body.reduce((a, b) => a < b ? a : b));
1352
- const key = body.slice(minIdx).concat(body.slice(0, minIdx)).join(">");
1353
- if (!seen.has(key)) {
1354
- seen.add(key);
1355
- cycles.push({ path: cycle });
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
- for (const n of adj.keys()) dfs(n);
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 analyzeProject(projectPath) {
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(sep3).join("/");
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
- const cycles = findCycles(edges);
1437
- const unusedFiles = codeFiles.filter((rel) => !inDegree.has(rel) && !looksLikeEntry(rel, pkg)).sort();
1438
- const otherText = /* @__PURE__ */ new Map();
1439
- for (const [file, text] of fileTexts) otherText.set(file, text);
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 codeFiles) {
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, text] of fileTexts) {
1545
+ for (const [otherFile, ids] of identifiersByFile) {
1446
1546
  if (otherFile === rel) continue;
1447
- if (new RegExp(`\\b${exp.name.replace(/[$_]/g, "\\$&")}\\b`).test(text)) {
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 [rel, text] of fileTexts) {
1458
- const score2 = complexityOf(text);
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 = [...fileTexts.values()].reduce((s, t2) => s + t2.split("\n").length, 0);
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 penalties = cycles.length * 6 + unusedFiles.length * 2 + Math.min(unusedExports.length, 20) * 1 + Math.round(dupLines / Math.max(codeLines, 1) * 100) + Math.min(hotspots.length, 15) * 2;
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: codeFiles.length,
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.path.join(" \u2192 ")}`);
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
- const report = await analyzeProject(project);
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(`