dsh-codebase-chat 0.23.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 CHANGED
@@ -28,14 +28,15 @@
28
28
  npx dsh-codebase-chat-mcp setup
29
29
  ```
30
30
 
31
- The wizard detects **Claude, Cursor, Windsurf and VS Code**, writes the MCP config, done.
32
- No JSON to edit — and **no API key**: in `promptOnly` mode your host model does the thinking.
31
+ The wizard detects **Claude, Cursor, Windsurf, VS Code, Zed, Gemini CLI, Kiro, Cline and Roo Code**, asks how you want answers (host model, API key, or offline local model), writes the MCP config, done.
32
+ No JSON to edit — and **no API key**: in `promptOnly` mode your host model does the thinking,
33
+ or go **fully offline** with the embedded local model (`localLlm`) — no key, no host, no cloud.
33
34
 
34
35
  Other paths — DeepSeek Harness plugin · CLI · from source · manual config: **[Reference](#reference)**.
35
36
 
36
37
  <p align="center">
37
- <img src="docs/assets/demo.gif" alt="dsh-codebase-chat CLI demo" width="840"><br>
38
- <em>Live terminal capture — <a href="docs/assets/demo-mcp.gif">MCP session</a> · <a href="docs/assets/demo-fr.gif">French mode</a></em>
38
+ <img src="docs/assets/demo-conv.gif" alt="dsh-codebase-chat real MCP session on a 422-file codebase" width="840"><br>
39
+ <em>Real MCP session on a real 422-file codebase — <code>codebase_health</code> finds 324 circular deps, <code>codebase_chat</code> answers with <code>[source: file:line]</code> receipts · <a href="docs/assets/demo-power.gif">PR review (--diff + --watch)</a> · <a href="docs/assets/demo.gif">CLI tour</a> · <a href="docs/assets/demo-mcp.gif">MCP stdio</a> · <a href="docs/assets/demo-fr.gif">French mode</a></em>
39
40
  </p>
40
41
 
41
42
  ## What a real session looks like
@@ -97,6 +98,7 @@ Every answer from `codebase_chat` arrives with `[source: file:line]` receipts yo
97
98
  | Code stays on your machine | ❌ | ❌ | ✅ |
98
99
  | Inside Claude / Cursor / Windsurf | ❌ | ~ | ✅ |
99
100
  | Deterministic health score, no LLM | ❌ | ❌ | ✅ |
101
+ | Answers fully offline (embedded LLM) | ❌ | ❌ | ✅ |
100
102
  | Free — no API key, no account | ~ | ❌ | ✅ |
101
103
 
102
104
  ## How it works
@@ -107,7 +109,7 @@ Every answer from `codebase_chat` arrives with `[source: file:line]` receipts yo
107
109
 
108
110
  `/codebase-apply` writes safely — **dry-run** · **`.dsh-backups/`** before overwrite · **protected paths** · never outside the project.
109
111
 
110
- ## The 12 tools
112
+ ## The 13 tools
111
113
 
112
114
  | Understand | Decide | Act | Explore |
113
115
  | --- | --- | --- | --- |
@@ -115,8 +117,9 @@ Every answer from `codebase_chat` arrives with `[source: file:line]` receipts yo
115
117
  | `codebase_search` | `codebase_audit` | `codebase_tasks` | `codebase_crea` |
116
118
  | `codebase_explain` | `codebase_report` | | |
117
119
  | `codebase_health` | `codebase_ceo` | | |
120
+ | `codebase_impact` | | | |
118
121
 
119
- Same engine, three surfaces: **MCP tools** in your IDE, **slash commands** in DeepSeek Harness, **CLI flags** anywhere. Every tool takes `lang` (`fr`/`en`), `embed`, `promptOnly`, `maxTokens`.
122
+ Same engine, three surfaces: **MCP tools** in your IDE, **slash commands** in DeepSeek Harness, **CLI flags** anywhere. Every tool takes `lang` (`fr`/`en`), `embed`, `promptOnly`, `localLlm`, `maxTokens`.
120
123
 
121
124
  ## Reference
122
125
 
@@ -222,6 +225,7 @@ dsh --profile headless '/codebase-git --project C:\my-app'
222
225
  | `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` | — | Direct-LLM mode only |
223
226
  | `DEEPSEEK_BASE_URL` / `OPENAI_BASE_URL` | `https://api.deepseek.com/v1` | Custom endpoint |
224
227
  | `CODEBASE_MODEL` | `deepseek-chat` | Model for direct-LLM mode |
228
+ | `CODEBASE_LOCAL_LLM` | — | `1` enables the embedded local model (offline answers); `hf:owner/repo:QUANT` or a `.gguf` path picks another model |
225
229
 
226
230
  </details>
227
231
 
@@ -265,10 +269,10 @@ pnpm install && pnpm build && pnpm test && pnpm typecheck
265
269
  <br>
266
270
 
267
271
  **Does it send my code to the cloud?**
268
- No. Indexing, retrieval, and prompt building run on your machine. In prompt-only mode, nothing leaves it at all.
272
+ Indexing, retrieval, and prompt building all run on your machine. In prompt-only mode the server makes no network calls itself — the assembled context is read by your host model (cloud or local, your choice). For zero-network answers end to end, enable the embedded local model (`localLlm`).
269
273
 
270
274
  **Do I need an API key?**
271
- Not for the MCP server without a key it returns the built prompt to the host model. A key is only needed for direct LLM calls. Inside DeepSeek Harness, the plugin uses your configured model.
275
+ No three ways to get answers: the host model (`promptOnly`, best quality), a DeepSeek/OpenAI key, or the embedded local model (`localLlm`, fully offline). The local model is small great for quick lookups, prefer a hosted model for full reports. Inside DeepSeek Harness, the plugin uses your configured model.
272
276
 
273
277
  **Which languages are supported?**
274
278
  French and English via `lang` on every tool. Source-side, AST covers JS/TS, Python, Go, Rust, Java, C#, PHP — the rest is indexed line by line.
@@ -290,7 +294,7 @@ Then restart `dsh --profile web`.
290
294
 
291
295
  | | |
292
296
  | --- | --- |
293
- | **Shipped** | tree-sitter AST (7 languages), deterministic health score, MCP setup wizard, `.codebase-chat.json`, `--diff` scoping, `--watch` mode |
297
+ | **Shipped** | tree-sitter AST (7 languages), deterministic health score, MCP setup wizard, `.codebase-chat.json`, `--diff` scoping, `--watch` mode, embedded local LLM |
294
298
  | **Next** | GitHub Issues export from TASKS.md, prompt language packs (ES/DE/PT) |
295
299
  | **Planned** | VS Code extension, HTTP/SSE transport, PR review mode, report export |
296
300
  | **Exploring** | multi-repo workspaces, shared team index cache, CI bot |
package/dist/cli.js CHANGED
@@ -650,7 +650,12 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
650
650
  ".vscode",
651
651
  "__pycache__",
652
652
  ".dsh-tmp",
653
- ".dsh-vision-router"
653
+ ".dsh-vision-router",
654
+ ".agents",
655
+ ".claude",
656
+ ".devin",
657
+ ".playwright-mcp",
658
+ ".windsurf"
654
659
  ]);
655
660
  var DEFAULT_SKIP_FILES = /* @__PURE__ */ new Set([]);
656
661
  async function getWalkOptions(absProject) {
@@ -665,11 +670,10 @@ function projectHash(absProject) {
665
670
  return createHash("sha256").update(absProject.toLowerCase()).digest("hex").slice(0, 16);
666
671
  }
667
672
  function resolveProjectPath(projectPath) {
668
- const raw = (projectPath ?? "").trim().toLowerCase().replace(/['"]/g, "");
669
- if (raw === "dako") return "D:\\Nouveau dossier";
670
- if (!projectPath) return process.cwd();
671
- if (isAbsolute(projectPath)) return resolve(projectPath);
672
- 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);
673
677
  }
674
678
  async function findProjectRoot(absProject) {
675
679
  try {
@@ -1105,9 +1109,12 @@ function lexicalScore(index, query) {
1105
1109
  const terms = tokenizeQuery(query);
1106
1110
  const scores = /* @__PURE__ */ new Map();
1107
1111
  if (terms.length === 0) return scores;
1112
+ const totalFiles = Math.max(Object.keys(index.files).length, 1);
1108
1113
  for (const term of terms) {
1109
1114
  const posting = index.terms[term];
1110
1115
  if (!posting) continue;
1116
+ const df = Object.keys(posting).length;
1117
+ const idf = Math.log(1 + totalFiles / df);
1111
1118
  for (const [relPath, count] of Object.entries(posting)) {
1112
1119
  const file = index.files[relPath];
1113
1120
  if (!file) continue;
@@ -1118,7 +1125,7 @@ function lexicalScore(index, query) {
1118
1125
  if (!contentHit && !nameHit) continue;
1119
1126
  const bonus = (nameHit ? 4 : 0) + (chunk.kind === "function" || chunk.kind === "method" ? 1 : 0) + (contentHit ? 1 : 0);
1120
1127
  const prev = scores.get(key) ?? 0;
1121
- scores.set(key, prev + count + bonus);
1128
+ scores.set(key, prev + count * idf + bonus);
1122
1129
  }
1123
1130
  }
1124
1131
  }
@@ -1223,12 +1230,23 @@ function getLabels(lang) {
1223
1230
  };
1224
1231
  }
1225
1232
  async function extractProductConstraints(absProject) {
1226
- 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
+ ];
1227
1245
  const constraints = [];
1228
1246
  for (const name of candidates) {
1229
1247
  const text = await safeReadText(join5(absProject, name));
1230
1248
  if (!text) continue;
1231
- 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;
1232
1250
  let m;
1233
1251
  while ((m = regex.exec(text)) !== null) {
1234
1252
  const line = m[0].replace(/\s+/g, " ").trim();
@@ -1384,36 +1402,58 @@ function parseExports(text) {
1384
1402
  }
1385
1403
  function findCycles(edges) {
1386
1404
  const adj = /* @__PURE__ */ new Map();
1405
+ const nodes = /* @__PURE__ */ new Set();
1387
1406
  for (const e of edges) {
1407
+ nodes.add(e.from);
1408
+ nodes.add(e.to);
1388
1409
  if (!adj.has(e.from)) adj.set(e.from, []);
1389
1410
  adj.get(e.from).push(e.to);
1390
1411
  }
1391
- const cycles = [];
1392
- const seen = /* @__PURE__ */ new Set();
1393
- const stack = [];
1412
+ const index = /* @__PURE__ */ new Map();
1413
+ const low = /* @__PURE__ */ new Map();
1394
1414
  const onStack = /* @__PURE__ */ new Set();
1395
- function dfs(node) {
1396
- stack.push(node);
1397
- onStack.add(node);
1398
- for (const next of adj.get(node) ?? []) {
1399
- if (onStack.has(next)) {
1400
- const cycle = stack.slice(stack.indexOf(next)).concat(next);
1401
- const body = cycle.slice(0, -1);
1402
- const minIdx = body.indexOf(body.reduce((a, b) => a < b ? a : b));
1403
- const key = body.slice(minIdx).concat(body.slice(0, minIdx)).join(">");
1404
- if (!seen.has(key)) {
1405
- seen.add(key);
1406
- 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);
1407
1452
  }
1408
- } else if (!stack.includes(next)) {
1409
- dfs(next);
1410
1453
  }
1411
1454
  }
1412
- stack.pop();
1413
- onStack.delete(node);
1414
1455
  }
1415
- for (const n of adj.keys()) dfs(n);
1416
- return cycles;
1456
+ return sccs.map((m) => ({ path: m.sort() })).sort((a, b) => b.path.length - a.path.length);
1417
1457
  }
1418
1458
  function looksLikeEntry(rel, pkg) {
1419
1459
  const base = basename(rel).toLowerCase().replace(extname2(rel), "");
@@ -1456,7 +1496,7 @@ function findDuplicates(fileTexts) {
1456
1496
  }
1457
1497
  return [...groups.values()].sort((a, b) => b.lines - a.lines).slice(0, 15);
1458
1498
  }
1459
- async function analyzeProject(projectPath, opts = {}) {
1499
+ async function collectImportGraph(projectPath) {
1460
1500
  const abs = await findProjectRoot(resolveProjectPath(projectPath));
1461
1501
  const fileTexts = /* @__PURE__ */ new Map();
1462
1502
  const codeFiles = [];
@@ -1465,17 +1505,11 @@ async function analyzeProject(projectPath, opts = {}) {
1465
1505
  const rel = relative3(abs, full).split(sep4).join("/");
1466
1506
  const ext = extname2(rel).toLowerCase();
1467
1507
  if (!CODE_EXTS.has(ext) || SKIP_EXTS.has(ext) || rel.includes(".min.")) continue;
1468
- if (opts.files && !opts.files.has(rel)) continue;
1469
1508
  const text = await safeReadText(full);
1470
1509
  if (!text) continue;
1471
1510
  codeFiles.push(rel);
1472
1511
  fileTexts.set(rel, text);
1473
1512
  }
1474
- let pkg = {};
1475
- try {
1476
- pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
1477
- } catch {
1478
- }
1479
1513
  const known = new Set(codeFiles);
1480
1514
  const edges = [];
1481
1515
  const inDegree = /* @__PURE__ */ new Map();
@@ -1485,18 +1519,32 @@ async function analyzeProject(projectPath, opts = {}) {
1485
1519
  inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
1486
1520
  }
1487
1521
  }
1488
- const cycles = findCycles(edges);
1489
- const unusedFiles = codeFiles.filter((rel) => !inDegree.has(rel) && !looksLikeEntry(rel, pkg)).sort();
1490
- const otherText = /* @__PURE__ */ new Map();
1491
- 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
+ }
1492
1540
  const unusedExports = [];
1493
- for (const rel of codeFiles) {
1541
+ for (const rel of scopedFiles) {
1494
1542
  for (const exp of parseExports(fileTexts.get(rel))) {
1495
1543
  if (exp.name === "default") continue;
1496
1544
  let used = false;
1497
- for (const [otherFile, text] of fileTexts) {
1545
+ for (const [otherFile, ids] of identifiersByFile) {
1498
1546
  if (otherFile === rel) continue;
1499
- if (new RegExp(`\\b${exp.name.replace(/[$_]/g, "\\$&")}\\b`).test(text)) {
1547
+ if (ids.has(exp.name)) {
1500
1548
  used = true;
1501
1549
  break;
1502
1550
  }
@@ -1504,21 +1552,22 @@ async function analyzeProject(projectPath, opts = {}) {
1504
1552
  if (!used) unusedExports.push({ file: rel, name: exp.name, line: exp.line });
1505
1553
  }
1506
1554
  }
1507
- const duplicates = findDuplicates(fileTexts);
1555
+ const duplicates = findDuplicates(fileTexts).filter((g) => !scope || g.files.some((f) => scope.has(f)));
1508
1556
  const hotspots = [];
1509
- for (const [rel, text] of fileTexts) {
1510
- const score2 = complexityOf(text);
1557
+ for (const rel of scopedFiles) {
1558
+ const score2 = complexityOf(fileTexts.get(rel));
1511
1559
  if (score2 >= 12) hotspots.push({ file: rel, startLine: 1, score: score2 });
1512
1560
  }
1513
1561
  hotspots.sort((a, b) => b.score - a.score);
1514
- 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);
1515
1563
  const dupLines = duplicates.reduce((s, g) => s + g.lines, 0);
1516
- 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;
1517
1566
  const score = Math.max(0, Math.min(100, 100 - penalties));
1518
1567
  const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 65 ? "C" : score >= 50 ? "D" : "E";
1519
1568
  return {
1520
1569
  projectPath: abs,
1521
- analyzedFiles: codeFiles.length,
1570
+ analyzedFiles: scopedFiles.length,
1522
1571
  importEdges: edges.length,
1523
1572
  cycles,
1524
1573
  unusedFiles,
@@ -1560,7 +1609,7 @@ function formatHealthReport(r, lang = "fr") {
1560
1609
  out.push(`${t2.score}: ${r.score}/100 (${r.grade}) \xB7 ${r.analyzedFiles} ${t2.files} \xB7 ${r.importEdges} ${t2.edges}`);
1561
1610
  out.push("");
1562
1611
  out.push(`\u25CF ${t2.cycles} (${r.cycles.length})`);
1563
- 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)}`);
1564
1613
  if (r.cycles.length === 0) out.push(` ${t2.none}`);
1565
1614
  out.push("");
1566
1615
  out.push(`\u25CF ${t2.unusedFiles} (${r.unusedFiles.length}) \u2014 ${t2.noteUnused}`);
@@ -1576,6 +1625,149 @@ function formatHealthReport(r, lang = "fr") {
1576
1625
  for (const h of r.hotspots.slice(0, 10)) out.push(` ${h.file} \u2014 score ${h.score}`);
1577
1626
  return out.join("\n");
1578
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
+ }
1579
1771
 
1580
1772
  // src/cli.ts
1581
1773
  var ExitSignal = class {
@@ -1600,6 +1792,7 @@ Usage:
1600
1792
  npx dsh-codebase-chat --project <path> --index
1601
1793
  npx dsh-codebase-chat --project <path> --stats
1602
1794
  npx dsh-codebase-chat --project <path> --health
1795
+ npx dsh-codebase-chat --project <path> --impact src/store.ts
1603
1796
  npx dsh-codebase-chat --project <path> --health --diff main
1604
1797
  npx dsh-codebase-chat --project <path> --watch
1605
1798
 
@@ -1611,6 +1804,7 @@ Options:
1611
1804
  -i, --index Force re-index the project
1612
1805
  -t, --stats Print indexing stats
1613
1806
  -H, --health Deterministic static analysis (cycles, dead code, dupes, complexity)
1807
+ --impact <file> Blast radius \u2014 which files transitively depend on <file>
1614
1808
  -d, --diff <ref> Scope --ask/--search/--health to files changed vs a git ref
1615
1809
  -w, --watch Keep the index hot \u2014 rebuild incrementally on file changes
1616
1810
  -e, --embed Enable local semantic embeddings (slower, more relevant)
@@ -1635,6 +1829,7 @@ async function main() {
1635
1829
  index: { type: "boolean", short: "i", default: false },
1636
1830
  stats: { type: "boolean", short: "t", default: false },
1637
1831
  health: { type: "boolean", short: "H", default: false },
1832
+ impact: { type: "string" },
1638
1833
  diff: { type: "string", short: "d" },
1639
1834
  watch: { type: "boolean", short: "w", default: false },
1640
1835
  embed: { type: "boolean", short: "e", default: false },
@@ -1713,6 +1908,17 @@ async function main() {
1713
1908
  console.log(formatHealthReport(report, lang));
1714
1909
  exit(0);
1715
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
+ }
1716
1922
  if (values.ask || values.search || values.file) {
1717
1923
  const result = await buildContext({
1718
1924
  project: values.project,