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 +13 -9
- package/dist/cli.js +257 -51
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +42 -2
- package/dist/index.js +304 -52
- package/dist/index.js.map +1 -1
- package/lib/index.js +57 -9
- package/package.json +4 -1
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
|
|
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
|
|
38
|
-
<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
|
|
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
|
-
|
|
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
|
-
|
|
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().
|
|
669
|
-
if (raw
|
|
670
|
-
if (
|
|
671
|
-
|
|
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 = [
|
|
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
|
|
1392
|
-
const
|
|
1393
|
-
const stack = [];
|
|
1412
|
+
const index = /* @__PURE__ */ new Map();
|
|
1413
|
+
const low = /* @__PURE__ */ new Map();
|
|
1394
1414
|
const onStack = /* @__PURE__ */ new Set();
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
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,
|
|
1545
|
+
for (const [otherFile, ids] of identifiersByFile) {
|
|
1498
1546
|
if (otherFile === rel) continue;
|
|
1499
|
-
if (
|
|
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
|
|
1510
|
-
const score2 = complexityOf(
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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,
|