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/dist/index.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ declare function getIndex(projectPath: string, progress?: (message: string) => v
|
|
|
79
79
|
|
|
80
80
|
declare function resolveProjectPath(projectPath?: string): string;
|
|
81
81
|
declare function findProjectRoot(absProject: string): Promise<string>;
|
|
82
|
+
declare function getCacheDir(): string;
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
85
|
* Count the exact number of tokens for a given text using the CL100K base
|
|
@@ -171,13 +172,52 @@ interface HealthReport {
|
|
|
171
172
|
grade: 'A' | 'B' | 'C' | 'D' | 'E';
|
|
172
173
|
}
|
|
173
174
|
interface AnalyzeOptions {
|
|
174
|
-
/** Restrict
|
|
175
|
+
/** Restrict *reported findings* to these project-relative paths (e.g. a diff
|
|
176
|
+
* scope). The import graph and usage checks still run on the whole project
|
|
177
|
+
* so cycles/uses crossing the scope boundary are detected. */
|
|
175
178
|
files?: Set<string>;
|
|
176
179
|
}
|
|
177
180
|
declare function analyzeProject(projectPath: string, opts?: AnalyzeOptions): Promise<HealthReport>;
|
|
178
181
|
declare function formatHealthReport(r: HealthReport, lang?: 'fr' | 'en'): string;
|
|
179
182
|
declare function formatHealthReportMd(r: HealthReport, lang?: 'fr' | 'en'): string;
|
|
180
183
|
|
|
184
|
+
interface ImpactDependent {
|
|
185
|
+
file: string;
|
|
186
|
+
depth: number;
|
|
187
|
+
}
|
|
188
|
+
interface ImpactReport {
|
|
189
|
+
projectPath: string;
|
|
190
|
+
query: string;
|
|
191
|
+
target: string;
|
|
192
|
+
exportedSymbols: {
|
|
193
|
+
name: string;
|
|
194
|
+
line: number;
|
|
195
|
+
}[];
|
|
196
|
+
dependents: ImpactDependent[];
|
|
197
|
+
directCount: number;
|
|
198
|
+
totalFiles: number;
|
|
199
|
+
/** Share of the project's code files that transitively depend on the target. */
|
|
200
|
+
percent: number;
|
|
201
|
+
inCycle: boolean;
|
|
202
|
+
isEntry: boolean;
|
|
203
|
+
risk: 'low' | 'medium' | 'high';
|
|
204
|
+
}
|
|
205
|
+
type ImpactResult = {
|
|
206
|
+
ok: true;
|
|
207
|
+
report: ImpactReport;
|
|
208
|
+
} | {
|
|
209
|
+
ok: false;
|
|
210
|
+
query: string;
|
|
211
|
+
candidates: string[];
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Blast-radius analysis: which files break if `query` changes. Reverse BFS on
|
|
215
|
+
* the local import graph — deterministic, no LLM.
|
|
216
|
+
*/
|
|
217
|
+
declare function analyzeImpact(projectPath: string, query: string): Promise<ImpactResult>;
|
|
218
|
+
declare function formatImpactReport(r: ImpactReport, lang?: 'fr' | 'en'): string;
|
|
219
|
+
declare function formatImpactReportMd(r: ImpactReport, lang?: 'fr' | 'en'): string;
|
|
220
|
+
|
|
181
221
|
type ConfigLang = 'fr' | 'en';
|
|
182
222
|
/**
|
|
183
223
|
* Per-project settings read from `.codebase-chat.json` at the project root.
|
|
@@ -221,4 +261,4 @@ interface DiffScope {
|
|
|
221
261
|
*/
|
|
222
262
|
declare function getChangedFiles(absProject: string, base: string): Promise<DiffScope>;
|
|
223
263
|
|
|
224
|
-
export { CONFIG_FILE, type CloneGroup, type CodeChunk, type CodeIndex, type ConfigLang, type ContextOptions, type ContextResult, type Cycle, type DiffScope, type HealthReport, type Hotspot, type IndexedFile, type InvertedIndex, type ProjectConfig, type UnusedExport, analyzeProject, buildContext, buildIndex, chunkByTokens, clearConfigCache, cosineSimilarity, countTokens, disposeTreeSitter, embedIndex, ensureTreeSitterForExt, extractChunks, findProjectRoot, formatHealthReport, formatHealthReportMd, getChangedFiles, getEmbedding, getEmbeddings, getExtractor, getIndex, globToRegExp, initTreeSitter, loadIndex, loadProjectConfig, matchesAnyGlob, resolveProjectPath, saveIndex, scoreChunks, selectChunks, treeSitterReady, truncateToTokens };
|
|
264
|
+
export { CONFIG_FILE, type CloneGroup, type CodeChunk, type CodeIndex, type ConfigLang, type ContextOptions, type ContextResult, type Cycle, type DiffScope, type HealthReport, type Hotspot, type ImpactDependent, type ImpactReport, type ImpactResult, type IndexedFile, type InvertedIndex, type ProjectConfig, type UnusedExport, analyzeImpact, analyzeProject, buildContext, buildIndex, chunkByTokens, clearConfigCache, cosineSimilarity, countTokens, disposeTreeSitter, embedIndex, ensureTreeSitterForExt, extractChunks, findProjectRoot, formatHealthReport, formatHealthReportMd, formatImpactReport, formatImpactReportMd, getCacheDir, getChangedFiles, getEmbedding, getEmbeddings, getExtractor, getIndex, globToRegExp, initTreeSitter, loadIndex, loadProjectConfig, matchesAnyGlob, resolveProjectPath, saveIndex, scoreChunks, selectChunks, treeSitterReady, truncateToTokens };
|
package/dist/index.js
CHANGED
|
@@ -647,7 +647,12 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
647
647
|
".vscode",
|
|
648
648
|
"__pycache__",
|
|
649
649
|
".dsh-tmp",
|
|
650
|
-
".dsh-vision-router"
|
|
650
|
+
".dsh-vision-router",
|
|
651
|
+
".agents",
|
|
652
|
+
".claude",
|
|
653
|
+
".devin",
|
|
654
|
+
".playwright-mcp",
|
|
655
|
+
".windsurf"
|
|
651
656
|
]);
|
|
652
657
|
var DEFAULT_SKIP_FILES = /* @__PURE__ */ new Set([]);
|
|
653
658
|
async function getWalkOptions(absProject) {
|
|
@@ -662,11 +667,10 @@ function projectHash(absProject) {
|
|
|
662
667
|
return createHash("sha256").update(absProject.toLowerCase()).digest("hex").slice(0, 16);
|
|
663
668
|
}
|
|
664
669
|
function resolveProjectPath(projectPath) {
|
|
665
|
-
const raw = (projectPath ?? "").trim().
|
|
666
|
-
if (raw
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
return resolve(process.cwd(), projectPath);
|
|
670
|
+
const raw = (projectPath ?? "").trim().replace(/['"]/g, "");
|
|
671
|
+
if (!raw) return process.cwd();
|
|
672
|
+
if (isAbsolute(raw)) return resolve(raw);
|
|
673
|
+
return resolve(process.cwd(), raw);
|
|
670
674
|
}
|
|
671
675
|
async function findProjectRoot(absProject) {
|
|
672
676
|
try {
|
|
@@ -1102,9 +1106,12 @@ function lexicalScore(index, query) {
|
|
|
1102
1106
|
const terms = tokenizeQuery(query);
|
|
1103
1107
|
const scores = /* @__PURE__ */ new Map();
|
|
1104
1108
|
if (terms.length === 0) return scores;
|
|
1109
|
+
const totalFiles = Math.max(Object.keys(index.files).length, 1);
|
|
1105
1110
|
for (const term of terms) {
|
|
1106
1111
|
const posting = index.terms[term];
|
|
1107
1112
|
if (!posting) continue;
|
|
1113
|
+
const df = Object.keys(posting).length;
|
|
1114
|
+
const idf = Math.log(1 + totalFiles / df);
|
|
1108
1115
|
for (const [relPath, count] of Object.entries(posting)) {
|
|
1109
1116
|
const file = index.files[relPath];
|
|
1110
1117
|
if (!file) continue;
|
|
@@ -1115,7 +1122,7 @@ function lexicalScore(index, query) {
|
|
|
1115
1122
|
if (!contentHit && !nameHit) continue;
|
|
1116
1123
|
const bonus = (nameHit ? 4 : 0) + (chunk.kind === "function" || chunk.kind === "method" ? 1 : 0) + (contentHit ? 1 : 0);
|
|
1117
1124
|
const prev = scores.get(key) ?? 0;
|
|
1118
|
-
scores.set(key, prev + count + bonus);
|
|
1125
|
+
scores.set(key, prev + count * idf + bonus);
|
|
1119
1126
|
}
|
|
1120
1127
|
}
|
|
1121
1128
|
}
|
|
@@ -1220,12 +1227,23 @@ function getLabels(lang) {
|
|
|
1220
1227
|
};
|
|
1221
1228
|
}
|
|
1222
1229
|
async function extractProductConstraints(absProject) {
|
|
1223
|
-
const candidates = [
|
|
1230
|
+
const candidates = [
|
|
1231
|
+
"README.md",
|
|
1232
|
+
"README.MD",
|
|
1233
|
+
"readme.md",
|
|
1234
|
+
"MEMORY.md",
|
|
1235
|
+
"CONTRIBUTING.md",
|
|
1236
|
+
"AGENTS.md",
|
|
1237
|
+
"CLAUDE.md",
|
|
1238
|
+
".windsurfrules",
|
|
1239
|
+
".cursorrules",
|
|
1240
|
+
".cursorrules.md"
|
|
1241
|
+
];
|
|
1224
1242
|
const constraints = [];
|
|
1225
1243
|
for (const name of candidates) {
|
|
1226
1244
|
const text = await safeReadText(join5(absProject, name));
|
|
1227
1245
|
if (!text) continue;
|
|
1228
|
-
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation)[\s\S]{0,200}/gi;
|
|
1246
|
+
const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation|never|always|jamais|toujours|zéro|zero)[\s\S]{0,200}/gi;
|
|
1229
1247
|
let m;
|
|
1230
1248
|
while ((m = regex.exec(text)) !== null) {
|
|
1231
1249
|
const line = m[0].replace(/\s+/g, " ").trim();
|
|
@@ -1381,36 +1399,58 @@ function parseExports(text) {
|
|
|
1381
1399
|
}
|
|
1382
1400
|
function findCycles(edges) {
|
|
1383
1401
|
const adj = /* @__PURE__ */ new Map();
|
|
1402
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
1384
1403
|
for (const e of edges) {
|
|
1404
|
+
nodes.add(e.from);
|
|
1405
|
+
nodes.add(e.to);
|
|
1385
1406
|
if (!adj.has(e.from)) adj.set(e.from, []);
|
|
1386
1407
|
adj.get(e.from).push(e.to);
|
|
1387
1408
|
}
|
|
1388
|
-
const
|
|
1389
|
-
const
|
|
1390
|
-
const stack = [];
|
|
1409
|
+
const index = /* @__PURE__ */ new Map();
|
|
1410
|
+
const low = /* @__PURE__ */ new Map();
|
|
1391
1411
|
const onStack = /* @__PURE__ */ new Set();
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1412
|
+
const stack = [];
|
|
1413
|
+
const sccs = [];
|
|
1414
|
+
let counter = 0;
|
|
1415
|
+
for (const root of nodes) {
|
|
1416
|
+
if (index.has(root)) continue;
|
|
1417
|
+
const work = [[root, 0]];
|
|
1418
|
+
while (work.length) {
|
|
1419
|
+
const top = work[work.length - 1];
|
|
1420
|
+
const [v, ci] = top;
|
|
1421
|
+
if (ci === 0) {
|
|
1422
|
+
index.set(v, counter);
|
|
1423
|
+
low.set(v, counter);
|
|
1424
|
+
counter++;
|
|
1425
|
+
stack.push(v);
|
|
1426
|
+
onStack.add(v);
|
|
1427
|
+
}
|
|
1428
|
+
const children = adj.get(v) ?? [];
|
|
1429
|
+
if (ci < children.length) {
|
|
1430
|
+
top[1] = ci + 1;
|
|
1431
|
+
const w = children[ci];
|
|
1432
|
+
if (!index.has(w)) work.push([w, 0]);
|
|
1433
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
1434
|
+
} else {
|
|
1435
|
+
work.pop();
|
|
1436
|
+
if (work.length) {
|
|
1437
|
+
const parent = work[work.length - 1][0];
|
|
1438
|
+
low.set(parent, Math.min(low.get(parent), low.get(v)));
|
|
1439
|
+
}
|
|
1440
|
+
if (low.get(v) === index.get(v)) {
|
|
1441
|
+
const scc = [];
|
|
1442
|
+
let w;
|
|
1443
|
+
do {
|
|
1444
|
+
w = stack.pop();
|
|
1445
|
+
onStack.delete(w);
|
|
1446
|
+
scc.push(w);
|
|
1447
|
+
} while (w !== v);
|
|
1448
|
+
if (scc.length > 1 || (adj.get(v) ?? []).includes(v)) sccs.push(scc);
|
|
1404
1449
|
}
|
|
1405
|
-
} else if (!stack.includes(next)) {
|
|
1406
|
-
dfs(next);
|
|
1407
1450
|
}
|
|
1408
1451
|
}
|
|
1409
|
-
stack.pop();
|
|
1410
|
-
onStack.delete(node);
|
|
1411
1452
|
}
|
|
1412
|
-
|
|
1413
|
-
return cycles;
|
|
1453
|
+
return sccs.map((m) => ({ path: m.sort() })).sort((a, b) => b.path.length - a.path.length);
|
|
1414
1454
|
}
|
|
1415
1455
|
function looksLikeEntry(rel, pkg) {
|
|
1416
1456
|
const base = basename(rel).toLowerCase().replace(extname2(rel), "");
|
|
@@ -1453,7 +1493,7 @@ function findDuplicates(fileTexts) {
|
|
|
1453
1493
|
}
|
|
1454
1494
|
return [...groups.values()].sort((a, b) => b.lines - a.lines).slice(0, 15);
|
|
1455
1495
|
}
|
|
1456
|
-
async function
|
|
1496
|
+
async function collectImportGraph(projectPath) {
|
|
1457
1497
|
const abs = await findProjectRoot(resolveProjectPath(projectPath));
|
|
1458
1498
|
const fileTexts = /* @__PURE__ */ new Map();
|
|
1459
1499
|
const codeFiles = [];
|
|
@@ -1462,17 +1502,11 @@ async function analyzeProject(projectPath, opts = {}) {
|
|
|
1462
1502
|
const rel = relative3(abs, full).split(sep4).join("/");
|
|
1463
1503
|
const ext = extname2(rel).toLowerCase();
|
|
1464
1504
|
if (!CODE_EXTS.has(ext) || SKIP_EXTS.has(ext) || rel.includes(".min.")) continue;
|
|
1465
|
-
if (opts.files && !opts.files.has(rel)) continue;
|
|
1466
1505
|
const text = await safeReadText(full);
|
|
1467
1506
|
if (!text) continue;
|
|
1468
1507
|
codeFiles.push(rel);
|
|
1469
1508
|
fileTexts.set(rel, text);
|
|
1470
1509
|
}
|
|
1471
|
-
let pkg = {};
|
|
1472
|
-
try {
|
|
1473
|
-
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1474
|
-
} catch {
|
|
1475
|
-
}
|
|
1476
1510
|
const known = new Set(codeFiles);
|
|
1477
1511
|
const edges = [];
|
|
1478
1512
|
const inDegree = /* @__PURE__ */ new Map();
|
|
@@ -1482,18 +1516,32 @@ async function analyzeProject(projectPath, opts = {}) {
|
|
|
1482
1516
|
inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
|
|
1483
1517
|
}
|
|
1484
1518
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1519
|
+
return { abs, codeFiles, fileTexts, edges, inDegree };
|
|
1520
|
+
}
|
|
1521
|
+
async function analyzeProject(projectPath, opts = {}) {
|
|
1522
|
+
const { abs, codeFiles, fileTexts, edges, inDegree } = await collectImportGraph(projectPath);
|
|
1523
|
+
const scope = opts.files;
|
|
1524
|
+
const scopedFiles = scope ? codeFiles.filter((f) => scope.has(f)) : codeFiles;
|
|
1525
|
+
let pkg = {};
|
|
1526
|
+
try {
|
|
1527
|
+
pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
const cycles = findCycles(edges).filter((c) => !scope || c.path.some((node) => scope.has(node)));
|
|
1531
|
+
const unusedFiles = scopedFiles.filter((rel) => !inDegree.has(rel) && !looksLikeEntry(rel, pkg)).sort();
|
|
1532
|
+
const IDENT_RE = /[A-Za-z_$][\w$]*/g;
|
|
1533
|
+
const identifiersByFile = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const [file, text] of fileTexts) {
|
|
1535
|
+
identifiersByFile.set(file, new Set(text.match(IDENT_RE) ?? []));
|
|
1536
|
+
}
|
|
1489
1537
|
const unusedExports = [];
|
|
1490
|
-
for (const rel of
|
|
1538
|
+
for (const rel of scopedFiles) {
|
|
1491
1539
|
for (const exp of parseExports(fileTexts.get(rel))) {
|
|
1492
1540
|
if (exp.name === "default") continue;
|
|
1493
1541
|
let used = false;
|
|
1494
|
-
for (const [otherFile,
|
|
1542
|
+
for (const [otherFile, ids] of identifiersByFile) {
|
|
1495
1543
|
if (otherFile === rel) continue;
|
|
1496
|
-
if (
|
|
1544
|
+
if (ids.has(exp.name)) {
|
|
1497
1545
|
used = true;
|
|
1498
1546
|
break;
|
|
1499
1547
|
}
|
|
@@ -1501,21 +1549,22 @@ async function analyzeProject(projectPath, opts = {}) {
|
|
|
1501
1549
|
if (!used) unusedExports.push({ file: rel, name: exp.name, line: exp.line });
|
|
1502
1550
|
}
|
|
1503
1551
|
}
|
|
1504
|
-
const duplicates = findDuplicates(fileTexts);
|
|
1552
|
+
const duplicates = findDuplicates(fileTexts).filter((g) => !scope || g.files.some((f) => scope.has(f)));
|
|
1505
1553
|
const hotspots = [];
|
|
1506
|
-
for (const
|
|
1507
|
-
const score2 = complexityOf(
|
|
1554
|
+
for (const rel of scopedFiles) {
|
|
1555
|
+
const score2 = complexityOf(fileTexts.get(rel));
|
|
1508
1556
|
if (score2 >= 12) hotspots.push({ file: rel, startLine: 1, score: score2 });
|
|
1509
1557
|
}
|
|
1510
1558
|
hotspots.sort((a, b) => b.score - a.score);
|
|
1511
|
-
const codeLines =
|
|
1559
|
+
const codeLines = scopedFiles.reduce((s, f) => s + fileTexts.get(f).split("\n").length, 0);
|
|
1512
1560
|
const dupLines = duplicates.reduce((s, g) => s + g.lines, 0);
|
|
1513
|
-
const
|
|
1561
|
+
const cyclesPenalty = cycles.reduce((s, c) => s + c.path.length, 0) * 2;
|
|
1562
|
+
const penalties = cyclesPenalty + unusedFiles.length * 2 + Math.min(unusedExports.length, 20) * 1 + Math.round(dupLines / Math.max(codeLines, 1) * 100) + Math.min(hotspots.length, 15) * 2;
|
|
1514
1563
|
const score = Math.max(0, Math.min(100, 100 - penalties));
|
|
1515
1564
|
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 65 ? "C" : score >= 50 ? "D" : "E";
|
|
1516
1565
|
return {
|
|
1517
1566
|
projectPath: abs,
|
|
1518
|
-
analyzedFiles:
|
|
1567
|
+
analyzedFiles: scopedFiles.length,
|
|
1519
1568
|
importEdges: edges.length,
|
|
1520
1569
|
cycles,
|
|
1521
1570
|
unusedFiles,
|
|
@@ -1557,7 +1606,7 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1557
1606
|
out.push(`${t2.score}: ${r.score}/100 (${r.grade}) \xB7 ${r.analyzedFiles} ${t2.files} \xB7 ${r.importEdges} ${t2.edges}`);
|
|
1558
1607
|
out.push("");
|
|
1559
1608
|
out.push(`\u25CF ${t2.cycles} (${r.cycles.length})`);
|
|
1560
|
-
for (const c of r.cycles.slice(0, 10)) out.push(` ${c
|
|
1609
|
+
for (const c of r.cycles.slice(0, 10)) out.push(` ${formatCycle(c)}`);
|
|
1561
1610
|
if (r.cycles.length === 0) out.push(` ${t2.none}`);
|
|
1562
1611
|
out.push("");
|
|
1563
1612
|
out.push(`\u25CF ${t2.unusedFiles} (${r.unusedFiles.length}) \u2014 ${t2.noteUnused}`);
|
|
@@ -1573,6 +1622,12 @@ function formatHealthReport(r, lang = "fr") {
|
|
|
1573
1622
|
for (const h of r.hotspots.slice(0, 10)) out.push(` ${h.file} \u2014 score ${h.score}`);
|
|
1574
1623
|
return out.join("\n");
|
|
1575
1624
|
}
|
|
1625
|
+
function formatCycle(c, md = false) {
|
|
1626
|
+
const q = (s) => md ? `\`${s}\`` : s;
|
|
1627
|
+
if (c.path.length <= 4) return c.path.map(q).join(" \u2192 ") + " \u2192 " + q(c.path[0]);
|
|
1628
|
+
const shown = c.path.slice(0, 4).map(q).join(", ");
|
|
1629
|
+
return `${c.path.length} files: ${shown}, \u2026`;
|
|
1630
|
+
}
|
|
1576
1631
|
var GRADE_ICON = { A: "\u{1F7E2}", B: "\u{1F535}", C: "\u{1F7E1}", D: "\u{1F7E0}", E: "\u{1F534}" };
|
|
1577
1632
|
function scoreBar(score) {
|
|
1578
1633
|
const filled = Math.round(score / 10);
|
|
@@ -1623,7 +1678,7 @@ function formatHealthReportMd(r, lang = "fr") {
|
|
|
1623
1678
|
out.push("");
|
|
1624
1679
|
out.push(`### ${t2.cycles} \u2014 ${r.cycles.length}`);
|
|
1625
1680
|
if (r.cycles.length === 0) out.push(t2.none);
|
|
1626
|
-
else for (const c of r.cycles.slice(0, 10)) out.push(`-
|
|
1681
|
+
else for (const c of r.cycles.slice(0, 10)) out.push(`- ${formatCycle(c, true)}`);
|
|
1627
1682
|
if (r.cycles.length > 10) out.push(t2.more(r.cycles.length - 10));
|
|
1628
1683
|
out.push("");
|
|
1629
1684
|
out.push(`### ${t2.unusedFiles} \u2014 ${r.unusedFiles.length}`);
|
|
@@ -1658,8 +1713,202 @@ function formatHealthReportMd(r, lang = "fr") {
|
|
|
1658
1713
|
} else out.push(t2.none);
|
|
1659
1714
|
return out.join("\n");
|
|
1660
1715
|
}
|
|
1716
|
+
|
|
1717
|
+
// src/impact.ts
|
|
1718
|
+
import { basename as basename2 } from "path";
|
|
1719
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1720
|
+
import { join as join7 } from "path";
|
|
1721
|
+
function matchRank(rel, q) {
|
|
1722
|
+
if (rel === q) return 0;
|
|
1723
|
+
if (rel.endsWith(`/${q}`)) return 1;
|
|
1724
|
+
if (basename2(rel) === q) return 2;
|
|
1725
|
+
if (rel.toLowerCase().includes(q.toLowerCase())) return 3;
|
|
1726
|
+
return -1;
|
|
1727
|
+
}
|
|
1728
|
+
function resolveTarget(codeFiles, query) {
|
|
1729
|
+
const q = query.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1730
|
+
if (!q) return { candidates: [] };
|
|
1731
|
+
const ranked = codeFiles.map((f) => ({ f, r: matchRank(f, q) })).filter((m) => m.r >= 0).sort((a, b) => a.r - b.r || a.f.length - b.f.length);
|
|
1732
|
+
if (!ranked.length) return { candidates: [] };
|
|
1733
|
+
const best = ranked.filter((m) => m.r === ranked[0].r);
|
|
1734
|
+
if (best.length === 1) return { target: best[0].f, candidates: [] };
|
|
1735
|
+
return { candidates: best.slice(0, 12).map((m) => m.f) };
|
|
1736
|
+
}
|
|
1737
|
+
async function analyzeImpact(projectPath, query) {
|
|
1738
|
+
const g = await collectImportGraph(projectPath);
|
|
1739
|
+
const { target, candidates } = resolveTarget(g.codeFiles, query);
|
|
1740
|
+
if (!target) return { ok: false, query, candidates };
|
|
1741
|
+
const rev = /* @__PURE__ */ new Map();
|
|
1742
|
+
for (const e of g.edges) {
|
|
1743
|
+
if (!rev.has(e.to)) rev.set(e.to, []);
|
|
1744
|
+
rev.get(e.to).push(e.from);
|
|
1745
|
+
}
|
|
1746
|
+
let inCycle = false;
|
|
1747
|
+
const queue = [target];
|
|
1748
|
+
let head = 0;
|
|
1749
|
+
const dist = /* @__PURE__ */ new Map([[target, 0]]);
|
|
1750
|
+
while (head < queue.length) {
|
|
1751
|
+
const node = queue[head++];
|
|
1752
|
+
const d = dist.get(node);
|
|
1753
|
+
for (const up of rev.get(node) ?? []) {
|
|
1754
|
+
if (up === target) {
|
|
1755
|
+
inCycle = true;
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
if (dist.has(up)) continue;
|
|
1759
|
+
dist.set(up, d + 1);
|
|
1760
|
+
queue.push(up);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
dist.delete(target);
|
|
1764
|
+
const dependents = [...dist.entries()].map(([file, d]) => ({ file, depth: d })).sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
1765
|
+
const directCount = dependents.filter((d) => d.depth === 1).length;
|
|
1766
|
+
let pkg = {};
|
|
1767
|
+
try {
|
|
1768
|
+
pkg = JSON.parse(await readFile5(join7(g.abs, "package.json"), "utf8"));
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
const isEntry = looksLikeEntry(target, pkg);
|
|
1772
|
+
const total = dependents.length;
|
|
1773
|
+
const percent = g.codeFiles.length ? total / g.codeFiles.length : 0;
|
|
1774
|
+
const risk = inCycle || percent >= 0.25 || total >= 25 ? "high" : percent >= 0.05 || total >= 5 ? "medium" : "low";
|
|
1775
|
+
return {
|
|
1776
|
+
ok: true,
|
|
1777
|
+
report: {
|
|
1778
|
+
projectPath: g.abs,
|
|
1779
|
+
query,
|
|
1780
|
+
target,
|
|
1781
|
+
exportedSymbols: parseExports(g.fileTexts.get(target) ?? ""),
|
|
1782
|
+
dependents,
|
|
1783
|
+
directCount,
|
|
1784
|
+
totalFiles: g.codeFiles.length,
|
|
1785
|
+
percent,
|
|
1786
|
+
inCycle,
|
|
1787
|
+
isEntry,
|
|
1788
|
+
risk
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
var RISK_ICON = { low: "\u{1F7E2}", medium: "\u{1F7E1}", high: "\u{1F534}" };
|
|
1793
|
+
function formatImpactReport(r, lang = "fr") {
|
|
1794
|
+
const t2 = lang === "en" ? {
|
|
1795
|
+
title: "IMPACT ANALYSIS",
|
|
1796
|
+
target: "Target",
|
|
1797
|
+
exports: "Exported symbols",
|
|
1798
|
+
direct: "Direct dependents",
|
|
1799
|
+
total: "Total blast radius",
|
|
1800
|
+
files: "code files",
|
|
1801
|
+
inCycle: "part of a dependency cycle",
|
|
1802
|
+
entry: "entry point",
|
|
1803
|
+
risk: "Risk",
|
|
1804
|
+
low: "LOW",
|
|
1805
|
+
medium: "MEDIUM",
|
|
1806
|
+
high: "HIGH",
|
|
1807
|
+
depth: "depth",
|
|
1808
|
+
none: "none \u2014 nothing imports this file",
|
|
1809
|
+
more: (n) => `\u2026and ${n} more`
|
|
1810
|
+
} : {
|
|
1811
|
+
title: "ANALYSE D\u2019IMPACT",
|
|
1812
|
+
target: "Cible",
|
|
1813
|
+
exports: "Symboles export\xE9s",
|
|
1814
|
+
direct: "D\xE9pendants directs",
|
|
1815
|
+
total: "Rayon d\u2019impact total",
|
|
1816
|
+
files: "fichiers de code",
|
|
1817
|
+
inCycle: "dans un cycle de d\xE9pendances",
|
|
1818
|
+
entry: "point d\u2019entr\xE9e",
|
|
1819
|
+
risk: "Risque",
|
|
1820
|
+
low: "FAIBLE",
|
|
1821
|
+
medium: "MOYEN",
|
|
1822
|
+
high: "\xC9LEV\xC9",
|
|
1823
|
+
depth: "profondeur",
|
|
1824
|
+
none: "aucun \u2014 rien n\u2019importe ce fichier",
|
|
1825
|
+
more: (n) => `\u2026et ${n} autres`
|
|
1826
|
+
};
|
|
1827
|
+
const tags = [r.isEntry ? t2.entry : "", r.inCycle ? t2.inCycle : ""].filter(Boolean).join(" \xB7 ");
|
|
1828
|
+
const out = [];
|
|
1829
|
+
out.push(`== ${t2.title} \u2014 ${basename2(r.projectPath)} ==`);
|
|
1830
|
+
out.push(`${t2.target}: ${r.target}${tags ? ` (${tags})` : ""}`);
|
|
1831
|
+
out.push(`${t2.exports}: ${r.exportedSymbols.length ? r.exportedSymbols.map((s) => s.name).join(", ") : "\u2014"}`);
|
|
1832
|
+
out.push(`${t2.direct}: ${r.directCount} \xB7 ${t2.total}: ${r.dependents.length} / ${r.totalFiles} ${t2.files} (${Math.round(r.percent * 100)}%)`);
|
|
1833
|
+
out.push(`${t2.risk}: ${t2[riskKey(r.risk)]}`);
|
|
1834
|
+
out.push("");
|
|
1835
|
+
if (!r.dependents.length) {
|
|
1836
|
+
out.push(` ${t2.none}`);
|
|
1837
|
+
return out.join("\n");
|
|
1838
|
+
}
|
|
1839
|
+
const byDepth = /* @__PURE__ */ new Map();
|
|
1840
|
+
for (const d of r.dependents) {
|
|
1841
|
+
if (!byDepth.has(d.depth)) byDepth.set(d.depth, []);
|
|
1842
|
+
byDepth.get(d.depth).push(d.file);
|
|
1843
|
+
}
|
|
1844
|
+
for (const [d, files] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1845
|
+
out.push(` ${t2.depth} ${d} (${files.length}):`);
|
|
1846
|
+
for (const f of files.slice(0, 12)) out.push(` ${f}`);
|
|
1847
|
+
if (files.length > 12) out.push(` ${t2.more(files.length - 12)}`);
|
|
1848
|
+
}
|
|
1849
|
+
return out.join("\n");
|
|
1850
|
+
}
|
|
1851
|
+
function riskKey(r) {
|
|
1852
|
+
return r;
|
|
1853
|
+
}
|
|
1854
|
+
function formatImpactReportMd(r, lang = "fr") {
|
|
1855
|
+
const t2 = lang === "en" ? {
|
|
1856
|
+
title: "Impact analysis",
|
|
1857
|
+
target: "Target",
|
|
1858
|
+
exports: "Exported symbols",
|
|
1859
|
+
direct: "Direct dependents",
|
|
1860
|
+
total: "Total blast radius",
|
|
1861
|
+
files: "code files",
|
|
1862
|
+
inCycle: "part of a dependency cycle",
|
|
1863
|
+
entry: "entry point",
|
|
1864
|
+
risk: "Risk",
|
|
1865
|
+
low: "LOW",
|
|
1866
|
+
medium: "MEDIUM",
|
|
1867
|
+
high: "HIGH",
|
|
1868
|
+
colDepth: "Depth",
|
|
1869
|
+
colFile: "File",
|
|
1870
|
+
none: "_None \u2014 nothing imports this file._",
|
|
1871
|
+
more: (n) => `_\u2026and ${n} more_`
|
|
1872
|
+
} : {
|
|
1873
|
+
title: "Analyse d\u2019impact",
|
|
1874
|
+
target: "Cible",
|
|
1875
|
+
exports: "Symboles export\xE9s",
|
|
1876
|
+
direct: "D\xE9pendants directs",
|
|
1877
|
+
total: "Rayon d\u2019impact total",
|
|
1878
|
+
files: "fichiers de code",
|
|
1879
|
+
inCycle: "dans un cycle de d\xE9pendances",
|
|
1880
|
+
entry: "point d\u2019entr\xE9e",
|
|
1881
|
+
risk: "Risque",
|
|
1882
|
+
low: "FAIBLE",
|
|
1883
|
+
medium: "MOYEN",
|
|
1884
|
+
high: "\xC9LEV\xC9",
|
|
1885
|
+
colDepth: "Profondeur",
|
|
1886
|
+
colFile: "Fichier",
|
|
1887
|
+
none: "_Aucun \u2014 rien n\u2019importe ce fichier._",
|
|
1888
|
+
more: (n) => `_\u2026et ${n} autres_`
|
|
1889
|
+
};
|
|
1890
|
+
const tags = [r.isEntry ? t2.entry : "", r.inCycle ? t2.inCycle : ""].filter(Boolean).join(" \xB7 ");
|
|
1891
|
+
const out = [];
|
|
1892
|
+
out.push(`## ${RISK_ICON[r.risk]} ${t2.title} \u2014 \`${basename2(r.projectPath)}\``);
|
|
1893
|
+
out.push("");
|
|
1894
|
+
out.push(`**${t2.target} : \`${r.target}\`**${tags ? ` \u2014 _${tags}_` : ""}`);
|
|
1895
|
+
out.push("");
|
|
1896
|
+
out.push(`- **${t2.exports}** : ${r.exportedSymbols.length ? r.exportedSymbols.map((s) => `\`${s.name}\``).join(", ") : "\u2014"}`);
|
|
1897
|
+
out.push(`- **${t2.direct}** : ${r.directCount} \xB7 **${t2.total}** : **${r.dependents.length}** / ${r.totalFiles} ${t2.files} (**${Math.round(r.percent * 100)}%**)`);
|
|
1898
|
+
out.push(`- **${t2.risk}** : **${t2[riskKey(r.risk)]}**`);
|
|
1899
|
+
out.push("");
|
|
1900
|
+
if (!r.dependents.length) {
|
|
1901
|
+
out.push(t2.none);
|
|
1902
|
+
return out.join("\n");
|
|
1903
|
+
}
|
|
1904
|
+
out.push(`| ${t2.colDepth} | ${t2.colFile} |`, "|---|---|");
|
|
1905
|
+
for (const d of r.dependents.slice(0, 30)) out.push(`| ${d.depth} | \`${d.file}\` |`);
|
|
1906
|
+
if (r.dependents.length > 30) out.push(`| | ${t2.more(r.dependents.length - 30)} |`);
|
|
1907
|
+
return out.join("\n");
|
|
1908
|
+
}
|
|
1661
1909
|
export {
|
|
1662
1910
|
CONFIG_FILE,
|
|
1911
|
+
analyzeImpact,
|
|
1663
1912
|
analyzeProject,
|
|
1664
1913
|
buildContext,
|
|
1665
1914
|
buildIndex,
|
|
@@ -1674,6 +1923,9 @@ export {
|
|
|
1674
1923
|
findProjectRoot,
|
|
1675
1924
|
formatHealthReport,
|
|
1676
1925
|
formatHealthReportMd,
|
|
1926
|
+
formatImpactReport,
|
|
1927
|
+
formatImpactReportMd,
|
|
1928
|
+
getCacheDir,
|
|
1677
1929
|
getChangedFiles,
|
|
1678
1930
|
getEmbedding,
|
|
1679
1931
|
getEmbeddings,
|