dsh-codebase-chat 0.25.6 → 0.28.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
@@ -3,7 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
5
  import { watch } from "fs";
6
- import { sep as sep5, basename as basename3 } from "path";
6
+ import { sep as sep5, basename as basename4 } from "path";
7
7
 
8
8
  // src/context.ts
9
9
  import { join as join5 } from "path";
@@ -1652,6 +1652,95 @@ function formatCycle(c, md = false) {
1652
1652
  const shown = c.path.slice(0, 4).map(q).join(", ");
1653
1653
  return `${c.path.length} files: ${shown}, \u2026`;
1654
1654
  }
1655
+ var GRADE_ICON = { A: "\u{1F7E2}", B: "\u{1F535}", C: "\u{1F7E1}", D: "\u{1F7E0}", E: "\u{1F534}" };
1656
+ function scoreBar(score) {
1657
+ const filled = Math.round(score / 10);
1658
+ return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
1659
+ }
1660
+ function formatHealthReportMd(r, lang = "fr") {
1661
+ const t2 = lang === "en" ? {
1662
+ title: "Static analysis",
1663
+ files: "files analyzed",
1664
+ edges: "local imports",
1665
+ cycles: "Circular dependencies",
1666
+ none: "None",
1667
+ unusedFiles: "Unused files (candidates)",
1668
+ unusedExports: "Unused exports (candidates)",
1669
+ dupes: "Duplicate code blocks",
1670
+ hotspots: "Complexity hotspots",
1671
+ score: "Health score",
1672
+ noteUnused: "_candidates \u2014 entry points and framework conventions excluded_",
1673
+ colFile: "File",
1674
+ colSymbol: "Symbol",
1675
+ colSize: "Size",
1676
+ colFiles: "Files",
1677
+ colScore: "Score",
1678
+ more: (n) => `_\u2026and ${n} more_`
1679
+ } : {
1680
+ title: "Analyse statique",
1681
+ files: "fichiers analys\xE9s",
1682
+ edges: "imports locaux",
1683
+ cycles: "D\xE9pendances circulaires",
1684
+ none: "Aucune",
1685
+ unusedFiles: "Fichiers inutilis\xE9s (candidats)",
1686
+ unusedExports: "Exports inutilis\xE9s (candidats)",
1687
+ dupes: "Blocs de code dupliqu\xE9s",
1688
+ hotspots: "Hotspots de complexit\xE9",
1689
+ score: "Score de sant\xE9",
1690
+ noteUnused: "_candidats \u2014 points d\u2019entr\xE9e et conventions exclus_",
1691
+ colFile: "Fichier",
1692
+ colSymbol: "Symbole",
1693
+ colSize: "Taille",
1694
+ colFiles: "Fichiers",
1695
+ colScore: "Score",
1696
+ more: (n) => `_\u2026et ${n} autres_`
1697
+ };
1698
+ const out = [];
1699
+ out.push(`## ${GRADE_ICON[r.grade]} ${t2.title} \u2014 \`${basename(r.projectPath)}\``);
1700
+ out.push("");
1701
+ if (r.analyzedFiles === 0) {
1702
+ out.push(lang === "en" ? "_No code files detected in this project \u2014 check the path or your `.codebase-chat.json` ignore rules._" : "_Aucun fichier de code d\xE9tect\xE9 dans ce projet \u2014 v\xE9rifie le chemin ou les r\xE8gles ignore de `.codebase-chat.json`._");
1703
+ return out.join("\n");
1704
+ }
1705
+ out.push(`**${t2.score} : ${scoreBar(r.score)} ${r.score}/100 (${r.grade})** \xB7 ${r.analyzedFiles} ${t2.files} \xB7 ${r.importEdges} ${t2.edges}`);
1706
+ out.push("");
1707
+ out.push(`### ${t2.cycles} \u2014 ${r.cycles.length}`);
1708
+ if (r.cycles.length === 0) out.push(t2.none);
1709
+ else for (const c of r.cycles.slice(0, 10)) out.push(`- ${formatCycle(c, true)}`);
1710
+ if (r.cycles.length > 10) out.push(t2.more(r.cycles.length - 10));
1711
+ out.push("");
1712
+ out.push(`### ${t2.unusedFiles} \u2014 ${r.unusedFiles.length}`);
1713
+ out.push(t2.noteUnused);
1714
+ if (r.unusedFiles.length) {
1715
+ out.push("", `| ${t2.colFile} |`, "|---|---|");
1716
+ for (const f of r.unusedFiles.slice(0, 15)) out.push(`| \`${f}\` |`);
1717
+ if (r.unusedFiles.length > 15) out.push(`| ${t2.more(r.unusedFiles.length - 15)} |`);
1718
+ }
1719
+ out.push("");
1720
+ out.push(`### ${t2.unusedExports} \u2014 ${r.unusedExports.length}`);
1721
+ if (r.unusedExports.length) {
1722
+ out.push("", `| ${t2.colFile} | ${t2.colSymbol} |`, "|---|---|");
1723
+ for (const e of r.unusedExports.slice(0, 15)) out.push(`| \`${e.file}:${e.line}\` | \`${e.name}\` |`);
1724
+ if (r.unusedExports.length > 15) out.push(`| ${t2.more(r.unusedExports.length - 15)} | |`);
1725
+ } else out.push(t2.none);
1726
+ out.push("");
1727
+ out.push(`### ${t2.dupes} \u2014 ${r.duplicates.length}`);
1728
+ if (r.duplicates.length) {
1729
+ out.push("", `| ${t2.colSize} | ${t2.colFiles} |`, "|---|---|");
1730
+ for (const d of r.duplicates.slice(0, 8)) {
1731
+ out.push(`| ${d.lines} \xD7 ${d.files.length} | ${d.files.map((f) => `\`${f}\``).join(", ")} |`);
1732
+ }
1733
+ if (r.duplicates.length > 8) out.push(`| ${t2.more(r.duplicates.length - 8)} | |`);
1734
+ } else out.push(t2.none);
1735
+ out.push("");
1736
+ out.push(`### ${t2.hotspots} \u2014 ${r.hotspots.length}`);
1737
+ if (r.hotspots.length) {
1738
+ out.push("", `| ${t2.colFile} | ${t2.colScore} |`, "|---|---|");
1739
+ for (const h of r.hotspots.slice(0, 10)) out.push(`| \`${h.file}\` | **${h.score}** |`);
1740
+ if (r.hotspots.length > 10) out.push(`| ${t2.more(r.hotspots.length - 10)} | |`);
1741
+ } else out.push(t2.none);
1742
+ return out.join("\n");
1743
+ }
1655
1744
 
1656
1745
  // src/impact.ts
1657
1746
  import { basename as basename2 } from "path";
@@ -2532,80 +2621,956 @@ function buildToolPrompt(tool, opts) {
2532
2621
  return normalizeLabels(builder(opts), opts.lang || "fr");
2533
2622
  }
2534
2623
 
2535
- // src/local-llm.ts
2536
- import { existsSync as existsSync2 } from "fs";
2537
- import { join as join8 } from "path";
2538
- var DEFAULT_MODEL_URI = "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M";
2539
- var LOCAL_CONTEXT_SIZE = 8192;
2540
- var LOCAL_MAX_TOKENS = 1536;
2541
- function isLocalLlmEnabled() {
2542
- return !!(process.env.CODEBASE_LOCAL_LLM || "").trim();
2624
+ // src/report.ts
2625
+ import { access, readFile as readFile6 } from "fs/promises";
2626
+ import { basename as basename3, extname as extname3, join as join8 } from "path";
2627
+ import { execFile as execFile2 } from "child_process";
2628
+ import { promisify as promisify2 } from "util";
2629
+ var run2 = promisify2(execFile2);
2630
+ var EXT_LANG = {
2631
+ ".ts": "TypeScript",
2632
+ ".tsx": "TypeScript (React)",
2633
+ ".js": "JavaScript",
2634
+ ".jsx": "JavaScript (React)",
2635
+ ".mjs": "JavaScript (ESM)",
2636
+ ".cjs": "JavaScript (CJS)",
2637
+ ".py": "Python",
2638
+ ".rs": "Rust",
2639
+ ".go": "Go",
2640
+ ".java": "Java",
2641
+ ".kt": "Kotlin",
2642
+ ".rb": "Ruby",
2643
+ ".php": "PHP",
2644
+ ".c": "C",
2645
+ ".h": "C/C++",
2646
+ ".cpp": "C++",
2647
+ ".cs": "C#",
2648
+ ".swift": "Swift",
2649
+ ".vue": "Vue",
2650
+ ".svelte": "Svelte",
2651
+ ".dart": "Dart",
2652
+ ".lua": "Lua"
2653
+ };
2654
+ function bar(score) {
2655
+ const filled = Math.round(score / 10);
2656
+ return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
2657
+ }
2658
+ var SMELL_PATS = [
2659
+ ["todo", /\b(?:TODO|FIXME|HACK|XXX|WIP)\b/i],
2660
+ ["console", /\bconsole\.(log|warn|error|debug|info)\s*\(/],
2661
+ ["tsIgnore", /@ts-(ignore|expect-error|nocheck)\b/],
2662
+ ["any", /:\s*any\b/],
2663
+ ["emptyCatch", /catch\s*\([^)]*\)\s*\{\s*\}/],
2664
+ ["debugger", /\bdebugger\s*;/],
2665
+ ["syncIo", /\b(readFileSync|writeFileSync|appendFileSync|readdirSync|mkdirSync|execSync)\s*\(/]
2666
+ ];
2667
+ var SEC_PATS = [
2668
+ ["secret", /(?:api[_-]?key|secret|passwd|password|token|private[_-]?key)\s*[:=]\s*['"`][A-Za-z0-9_\/+\-.]{8,}['"`]/i],
2669
+ ["eval", /\beval\s*\(|new\s+Function\s*\(/],
2670
+ ["exec", /\bexecSync\s*\(|child_process/],
2671
+ ["innerHTML", /\.innerHTML\s*=/],
2672
+ ["unsafeRegex", /new\s+RegExp\s*\([^'"`]/]
2673
+ ];
2674
+ function scanCode(fileTexts, pats, perFileCap = 3) {
2675
+ const out = {};
2676
+ for (const [file, text] of fileTexts) {
2677
+ if (/test|spec|__tests__|\.d\.ts$/i.test(file)) continue;
2678
+ const lines = text.split("\n");
2679
+ for (const [key, re] of pats) {
2680
+ let found = 0;
2681
+ for (let i = 0; i < lines.length && found < perFileCap; i++) {
2682
+ if (re.test(lines[i])) {
2683
+ (out[key] ??= []).push({ file, line: i + 1, sample: lines[i].trim().slice(0, 90) });
2684
+ found++;
2685
+ }
2686
+ }
2687
+ }
2688
+ }
2689
+ return out;
2543
2690
  }
2544
- function configuredModel() {
2545
- const v = (process.env.CODEBASE_LOCAL_LLM || "").trim();
2546
- if (!v || v === "1" || v.toLowerCase() === "true") return DEFAULT_MODEL_URI;
2547
- return v;
2691
+ function countHits(scan) {
2692
+ return Object.values(scan).reduce((s, f) => s + f.length, 0);
2548
2693
  }
2549
- async function importLlama() {
2694
+ var SENSITIVE_PATS = /(^|\/)\.env$|(^|\/)\.env\.(local|prod|production|dev|development)$|\.(pem|key|p12|pfx|keystore)$|id_rsa|id_ed25519|credentials\.json|service-account/i;
2695
+ async function gitActivity(abs) {
2550
2696
  try {
2551
- return await import("node-llama-cpp");
2697
+ const { stdout } = await run2("git", [
2698
+ "-C",
2699
+ abs,
2700
+ "log",
2701
+ "--numstat",
2702
+ "--format=@@@%an|%ad|%s",
2703
+ "--date=short",
2704
+ "-n",
2705
+ "400"
2706
+ ], { maxBuffer: 32 * 1024 * 1024 });
2707
+ const stats = { commits: 0, authors: /* @__PURE__ */ new Map(), lastDate: "", churn: /* @__PURE__ */ new Map(), fileAuthors: /* @__PURE__ */ new Map(), months: /* @__PURE__ */ new Map(), sensitiveTracked: [], fileLastCommit: /* @__PURE__ */ new Map(), subjects: [], commitSizes: [] };
2708
+ let author = "";
2709
+ let date = "";
2710
+ let curFiles = 0, curLines = 0;
2711
+ const flush = () => {
2712
+ if (curFiles || curLines) stats.commitSizes.push({ files: curFiles, lines: curLines });
2713
+ curFiles = 0;
2714
+ curLines = 0;
2715
+ };
2716
+ for (const line of stdout.split("\n")) {
2717
+ if (line.startsWith("@@@")) {
2718
+ flush();
2719
+ stats.commits++;
2720
+ const [a, d, s] = line.slice(3).split("|");
2721
+ author = a;
2722
+ date = d;
2723
+ if (s) stats.subjects.push(s);
2724
+ if (!stats.lastDate) stats.lastDate = d;
2725
+ stats.authors.set(a, (stats.authors.get(a) ?? 0) + 1);
2726
+ const month = d.slice(0, 7);
2727
+ stats.months.set(month, (stats.months.get(month) ?? 0) + 1);
2728
+ continue;
2729
+ }
2730
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
2731
+ if (!m || m[1] === "-") continue;
2732
+ const file = m[3].replace(/\\/g, "/");
2733
+ const delta = Number(m[1]) + Number(m[2]);
2734
+ curFiles++;
2735
+ curLines += delta;
2736
+ stats.churn.set(file, (stats.churn.get(file) ?? 0) + delta);
2737
+ if (!stats.fileLastCommit.has(file) && date) stats.fileLastCommit.set(file, date);
2738
+ if (author) (stats.fileAuthors.get(file) ?? stats.fileAuthors.set(file, /* @__PURE__ */ new Set()).get(file)).add(author);
2739
+ }
2740
+ flush();
2741
+ try {
2742
+ const { stdout: tracked } = await run2("git", ["-C", abs, "ls-files"], { maxBuffer: 8 * 1024 * 1024 });
2743
+ stats.sensitiveTracked = tracked.split("\n").map((l) => l.trim()).filter((f) => f && SENSITIVE_PATS.test(f));
2744
+ } catch {
2745
+ }
2746
+ return stats;
2552
2747
  } catch {
2553
- throw new Error("Local LLM needs the optional dependency: npm i node-llama-cpp");
2554
- }
2555
- }
2556
- var modelPromise = null;
2557
- function loadLocalModel() {
2558
- if (!modelPromise) {
2559
- modelPromise = (async () => {
2560
- const spec = configuredModel();
2561
- let modelPath = spec;
2562
- if (spec.startsWith("hf:")) {
2563
- const { createModelDownloader } = await importLlama();
2564
- const downloader = await createModelDownloader({
2565
- modelUri: spec,
2566
- dirPath: join8(getCacheDir(), "models")
2567
- });
2568
- modelPath = await downloader.download();
2569
- } else if (!existsSync2(spec)) {
2570
- throw new Error(`Local model not found: ${spec}`);
2748
+ return null;
2749
+ }
2750
+ }
2751
+ var SYSTEM_ENV = /* @__PURE__ */ new Set([
2752
+ "PATH",
2753
+ "PATHEXT",
2754
+ "HOME",
2755
+ "HOMEPATH",
2756
+ "USERPROFILE",
2757
+ "USERNAME",
2758
+ "USER",
2759
+ "APPDATA",
2760
+ "LOCALAPPDATA",
2761
+ "TEMP",
2762
+ "TMP",
2763
+ "TMPDIR",
2764
+ "OS",
2765
+ "COMSPEC",
2766
+ "SYSTEMROOT",
2767
+ "WINDIR",
2768
+ "PROGRAMFILES",
2769
+ "PROGRAMDATA",
2770
+ "NUMBER_OF_PROCESSORS",
2771
+ "PROCESSOR_ARCHITECTURE",
2772
+ "SHELL",
2773
+ "TERM",
2774
+ "PWD",
2775
+ "OLDPWD",
2776
+ "HOME",
2777
+ "LANG",
2778
+ "LC_ALL",
2779
+ "TZ",
2780
+ "NODE_ENV",
2781
+ "NODE_PATH",
2782
+ "npm_config_cache",
2783
+ "CI",
2784
+ "HOSTNAME"
2785
+ ]);
2786
+ async function envAudit(abs, fileTexts) {
2787
+ const used = /* @__PURE__ */ new Set();
2788
+ for (const [file, text] of fileTexts) {
2789
+ if (/test|spec|__tests__/i.test(file)) continue;
2790
+ for (const m of text.matchAll(/\bprocess\.env\.([A-Z_][A-Z0-9_]*)/g)) used.add(m[1]);
2791
+ for (const m of text.matchAll(/\bimport\.meta\.env\.([A-Z_][A-Z0-9_]*)/g)) used.add(m[1]);
2792
+ }
2793
+ const declared = /* @__PURE__ */ new Set();
2794
+ for (const envFile of [".env.example", ".env.sample", ".env.template"]) {
2795
+ try {
2796
+ const text = await readFile6(join8(abs, envFile), "utf8");
2797
+ for (const m of text.matchAll(/^\s*([A-Z_][A-Z0-9_]*)\s*=/gm)) declared.add(m[1]);
2798
+ } catch {
2799
+ }
2800
+ }
2801
+ const projectVars = [...used].filter((v) => !SYSTEM_ENV.has(v));
2802
+ const undocumented = projectVars.filter((v) => !declared.has(v)).sort();
2803
+ return { used: projectVars.sort(), undocumented, hasTemplate: declared.size > 0 };
2804
+ }
2805
+ function unusedDeps(deps, imported) {
2806
+ return deps.filter((d) => ![...imported].some((i) => pkgRoot(i) === d));
2807
+ }
2808
+ async function configAudit(abs, pkg, _indexPaths, isGit) {
2809
+ let tsStrict = null;
2810
+ let gitignore = false;
2811
+ try {
2812
+ const raw = await readFile6(join8(abs, "tsconfig.json"), "utf8");
2813
+ const clean = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
2814
+ tsStrict = JSON.parse(clean)?.compilerOptions?.strict === true;
2815
+ } catch {
2816
+ }
2817
+ try {
2818
+ await access(join8(abs, ".gitignore"));
2819
+ gitignore = true;
2820
+ } catch {
2821
+ }
2822
+ const pkgMissing = ["license", "repository", "engines"].filter((k) => !pkg[k]);
2823
+ return { tsStrict, gitignore, pkgMissing, isGit };
2824
+ }
2825
+ function functionHotspots(index) {
2826
+ const out = [];
2827
+ for (const f of Object.values(index.files)) {
2828
+ if (/test|spec|__tests__|\.d\.ts$/i.test(f.relPath)) continue;
2829
+ for (const c of f.chunks) {
2830
+ if ((c.kind === "function" || c.kind === "method" || c.kind === "class") && c.name)
2831
+ out.push({ file: f.relPath, name: c.name, lines: c.endLine - c.startLine + 1 });
2832
+ }
2833
+ }
2834
+ return out.sort((a, b) => b.lines - a.lines).slice(0, 6);
2835
+ }
2836
+ async function brokenPkgEntries(abs, pkg) {
2837
+ const targets = [];
2838
+ if (typeof pkg.main === "string") targets.push(pkg.main);
2839
+ if (typeof pkg.bin === "string") targets.push(pkg.bin);
2840
+ else if (pkg.bin && typeof pkg.bin === "object") targets.push(...Object.values(pkg.bin).filter((v) => typeof v === "string"));
2841
+ const walkExports = (e) => {
2842
+ if (typeof e === "string" && e.startsWith(".")) targets.push(e);
2843
+ else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
2844
+ };
2845
+ walkExports(pkg.exports);
2846
+ const broken = [];
2847
+ for (const t2 of [...new Set(targets)]) {
2848
+ try {
2849
+ await access(join8(abs, t2));
2850
+ } catch {
2851
+ broken.push(t2);
2852
+ }
2853
+ }
2854
+ return broken;
2855
+ }
2856
+ function deepImports(fileTexts) {
2857
+ const out = [];
2858
+ const re = /from\s+['"]((?:\.\.\/){3,}[^'"]*)['"]/;
2859
+ for (const [file, text] of fileTexts) {
2860
+ if (/test|spec|__tests__/i.test(file)) continue;
2861
+ const lines = text.split("\n");
2862
+ for (let i = 0; i < lines.length; i++) {
2863
+ const m = lines[i].match(re);
2864
+ if (m) out.push({ file, line: i + 1, sample: m[1] });
2865
+ }
2866
+ }
2867
+ return out.slice(0, 8);
2868
+ }
2869
+ function codeShape(fileTexts) {
2870
+ let commentLines = 0, codeLines = 0;
2871
+ const deepNest = [];
2872
+ for (const [file, text] of fileTexts) {
2873
+ if (/test|spec|__tests__|\.d\.ts$/i.test(file)) continue;
2874
+ let maxDepth = 0, inBlock = false;
2875
+ for (const line of text.split("\n")) {
2876
+ const t2 = line.trim();
2877
+ if (!t2) continue;
2878
+ codeLines++;
2879
+ if (inBlock) {
2880
+ commentLines++;
2881
+ if (t2.includes("*/")) inBlock = false;
2882
+ continue;
2571
2883
  }
2572
- const { getLlama } = await importLlama();
2573
- const cpuOnly = /^(0|off|false|cpu)$/i.test(process.env.CODEBASE_LOCAL_GPU || "");
2574
- const llama = await getLlama(cpuOnly ? { gpu: false } : {});
2575
- try {
2576
- return await llama.loadModel({ modelPath });
2577
- } catch (err) {
2578
- if (cpuOnly) throw err;
2579
- console.error(`[local-llm] GPU load failed (${err instanceof Error ? err.message : err}) \u2014 falling back to CPU. Slower, but works. Set CODEBASE_LOCAL_GPU=off to skip GPU entirely.`);
2580
- const cpuLlama = await getLlama({ gpu: false });
2581
- return cpuLlama.loadModel({ modelPath });
2884
+ if (t2.startsWith("//") || t2.startsWith("*")) {
2885
+ commentLines++;
2886
+ continue;
2582
2887
  }
2583
- })();
2584
- modelPromise.catch(() => {
2585
- modelPromise = null;
2586
- });
2888
+ if (t2.startsWith("/*")) {
2889
+ commentLines++;
2890
+ if (!t2.includes("*/")) inBlock = true;
2891
+ continue;
2892
+ }
2893
+ const indent = line.match(/^[\t ]*/)[0];
2894
+ const depth = indent.replace(/\t/g, " ").length / 4;
2895
+ if (depth > maxDepth) maxDepth = depth;
2896
+ }
2897
+ if (maxDepth >= 6) deepNest.push({ file, depth: Math.round(maxDepth) });
2587
2898
  }
2588
- return modelPromise;
2899
+ return { commentPct: codeLines ? Math.round(commentLines / codeLines * 100) : 0, deepNest: deepNest.sort((a, b) => b.depth - a.depth).slice(0, 5) };
2589
2900
  }
2590
- async function callLocalLlm(prompt, lang = "fr") {
2591
- const model = await loadLocalModel();
2592
- const { LlamaChatSession } = await importLlama();
2593
- const context = await model.createContext({ contextSize: LOCAL_CONTEXT_SIZE });
2901
+ async function readmeAudit(abs) {
2594
2902
  try {
2595
- const session = new LlamaChatSession({
2596
- contextSequence: context.getSequence(),
2597
- systemPrompt: lang === "en" ? "You are a senior codebase analyst. Be precise and cite files with [source: path:line]." : "Tu es un analyste codebase senior. Sois precis et cite les fichiers avec [source: chemin:ligne]."
2903
+ const text = await readFile6(join8(abs, "README.md"), "utf8");
2904
+ return {
2905
+ install: /^#{1,3}.*(install|installation|getting started|démarrage)/im.test(text),
2906
+ usage: /^#{1,3}.*(usage|utilisation|quickstart|quick start)/im.test(text),
2907
+ codeBlocks: (text.match(/```/g) ?? []).length / 2,
2908
+ badges: (text.match(/!\[/g) ?? []).length
2909
+ };
2910
+ } catch {
2911
+ return null;
2912
+ }
2913
+ }
2914
+ function commitQuality(subjects) {
2915
+ if (!subjects.length) return null;
2916
+ const CONV = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert|tweak|release|hotfix|init|merge|wip)(\(.+\))?!?:\s/i;
2917
+ const conv = subjects.filter((s) => CONV.test(s)).length;
2918
+ const avgLen = Math.round(subjects.reduce((s, x) => s + x.length, 0) / subjects.length);
2919
+ return { conventionalPct: Math.round(conv / subjects.length * 100), avgLen };
2920
+ }
2921
+ var NODE_BUILTINS = /* @__PURE__ */ new Set([
2922
+ "assert",
2923
+ "buffer",
2924
+ "child_process",
2925
+ "cluster",
2926
+ "console",
2927
+ "constants",
2928
+ "crypto",
2929
+ "dgram",
2930
+ "dns",
2931
+ "domain",
2932
+ "events",
2933
+ "fs",
2934
+ "http",
2935
+ "http2",
2936
+ "https",
2937
+ "inspector",
2938
+ "module",
2939
+ "net",
2940
+ "os",
2941
+ "path",
2942
+ "perf_hooks",
2943
+ "process",
2944
+ "punycode",
2945
+ "querystring",
2946
+ "readline",
2947
+ "repl",
2948
+ "stream",
2949
+ "string_decoder",
2950
+ "sys",
2951
+ "timers",
2952
+ "tls",
2953
+ "tty",
2954
+ "url",
2955
+ "util",
2956
+ "v8",
2957
+ "vm",
2958
+ "worker_threads",
2959
+ "zlib"
2960
+ ]);
2961
+ function pkgRoot(spec) {
2962
+ const s = spec.startsWith("node:") ? spec.slice(5) : spec;
2963
+ if (s.startsWith("@")) return s.split("/").slice(0, 2).join("/");
2964
+ return s.split("/")[0];
2965
+ }
2966
+ function importedPackages(fileTexts) {
2967
+ const imported = /* @__PURE__ */ new Set();
2968
+ const IMPORT_RE2 = /(?:\bfrom\s+|\bimport\s*\(|\bimport\s+|\brequire\s*\()\s*['"]([^'"./][^'"]*)['"]/g;
2969
+ for (const text of fileTexts.values())
2970
+ for (const m of text.matchAll(IMPORT_RE2)) imported.add(m[1]);
2971
+ return imported;
2972
+ }
2973
+ async function missingDeps(abs, fileTexts, pkg, indexPaths) {
2974
+ const declared = new Set([pkg.name].filter(Boolean));
2975
+ const addDeps = (p) => ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].forEach((k) => Object.keys(p[k] ?? {}).forEach((d) => declared.add(d)));
2976
+ addDeps(pkg);
2977
+ for (const p of indexPaths) {
2978
+ if (!/(^|\/)package\.json$/.test(p) || p === "package.json") continue;
2979
+ try {
2980
+ addDeps(JSON.parse(await readFile6(join8(abs, p), "utf8")));
2981
+ } catch {
2982
+ }
2983
+ }
2984
+ const missing = /* @__PURE__ */ new Set();
2985
+ for (const spec of importedPackages(fileTexts)) {
2986
+ const root = pkgRoot(spec);
2987
+ if (!NODE_BUILTINS.has(root) && !declared.has(root)) missing.add(root);
2988
+ }
2989
+ return [...missing].sort();
2990
+ }
2991
+ async function lockfileDrift(abs, deps) {
2992
+ for (const lf of ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock"]) {
2993
+ try {
2994
+ const text = await readFile6(join8(abs, lf), "utf8");
2995
+ return deps.filter((d) => !text.includes(d));
2996
+ } catch {
2997
+ }
2998
+ }
2999
+ return [];
3000
+ }
3001
+ function functionComplexity(index) {
3002
+ const BRANCH = /\b(if|for|while|case|catch)\b|&&|\|\||\?/g;
3003
+ const out = [];
3004
+ for (const f of Object.values(index.files)) {
3005
+ if (/test|spec|__tests__|\.d\.ts$/i.test(f.relPath)) continue;
3006
+ for (const c of f.chunks) {
3007
+ if ((c.kind === "function" || c.kind === "method") && c.name)
3008
+ out.push({ file: f.relPath, name: c.name, score: (c.content.match(BRANCH) ?? []).length });
3009
+ }
3010
+ }
3011
+ return out.sort((a, b) => b.score - a.score).slice(0, 6);
3012
+ }
3013
+ function duplicateNames(codeFiles) {
3014
+ const byName = /* @__PURE__ */ new Map();
3015
+ for (const f of codeFiles) {
3016
+ const b = basename3(f).toLowerCase();
3017
+ (byName.get(b) ?? byName.set(b, []).get(b)).push(f);
3018
+ }
3019
+ return [...byName.entries()].filter(([n, fs]) => fs.length > 1 && !/^(index|types?|constants?|config)\./.test(n)).map(([name, files]) => ({ name, files })).slice(0, 5);
3020
+ }
3021
+ function asyncWithoutAwait(index) {
3022
+ const out = [];
3023
+ for (const f of Object.values(index.files)) {
3024
+ if (/test|spec|__tests__|\.d\.ts$/i.test(f.relPath)) continue;
3025
+ for (const c of f.chunks) {
3026
+ if ((c.kind === "function" || c.kind === "method") && c.name && /\basync\b/.test(c.content.split("\n")[0]) && !/\bawait\b/.test(c.content))
3027
+ out.push({ file: f.relPath, name: c.name });
3028
+ }
3029
+ }
3030
+ return out.slice(0, 6);
3031
+ }
3032
+ function graphDepth(edges, entryPoints) {
3033
+ const adj = /* @__PURE__ */ new Map();
3034
+ for (const e of edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)).push(e.to);
3035
+ let max = 0;
3036
+ const memo = /* @__PURE__ */ new Map();
3037
+ const dfs = (f, seen) => {
3038
+ if (memo.has(f)) return memo.get(f);
3039
+ if (seen.has(f)) return 0;
3040
+ seen.add(f);
3041
+ let d = 0;
3042
+ for (const t2 of adj.get(f) ?? []) d = Math.max(d, dfs(t2, seen) + 1);
3043
+ seen.delete(f);
3044
+ memo.set(f, d);
3045
+ return d;
3046
+ };
3047
+ for (const e of entryPoints) max = Math.max(max, dfs(e, /* @__PURE__ */ new Set()));
3048
+ return max;
3049
+ }
3050
+ function docCoverage(fileTexts) {
3051
+ let documented = 0, total = 0;
3052
+ const EXPORT_LINE = /^\s*export\s+(?:async\s+)?(?:function|class|const|let|interface|type|enum|default)\b/;
3053
+ for (const [file, text] of fileTexts) {
3054
+ if (/test|spec|__tests__|\.d\.ts$/i.test(file)) continue;
3055
+ const lines = text.split("\n");
3056
+ for (let i = 0; i < lines.length; i++) {
3057
+ if (!EXPORT_LINE.test(lines[i])) continue;
3058
+ total++;
3059
+ let j = i - 1;
3060
+ while (j >= 0 && !lines[j].trim()) j--;
3061
+ if (j >= 0 && /^\s*(\/\/|\/\*|\*)/.test(lines[j])) documented++;
3062
+ }
3063
+ }
3064
+ return { documented, total };
3065
+ }
3066
+ function detectInfra(indexPaths) {
3067
+ const found = [];
3068
+ const has = (p) => indexPaths.has(p) || [...indexPaths].some((f) => f.startsWith(p));
3069
+ if (has(".github/workflows")) found.push("CI (GitHub Actions)");
3070
+ if (has("dockerfile") || has("docker-compose.yml")) found.push("Docker");
3071
+ if (has("tsconfig.json")) found.push("TypeScript config");
3072
+ if (has("pnpm-lock.yaml") || has("package-lock.json") || has("yarn.lock")) found.push("lockfile");
3073
+ if (has("vitest.config") || has("jest.config")) found.push("test runner config");
3074
+ if (has(".env.example") || has(".env.sample")) found.push(".env template");
3075
+ if (has("dockerfile")) found.push("container");
3076
+ if (has("vercel.json") || has("netlify.toml")) found.push("deploy config");
3077
+ if (has("eslint.config") || has(".eslintrc")) found.push("linter config");
3078
+ if (has(".prettierrc") || has("prettier.config")) found.push("formatter config");
3079
+ return [...new Set(found)];
3080
+ }
3081
+ function recommendations(r, hasTests, smells, sec, git2, infra, riskFiles, extras, lang) {
3082
+ const en = lang === "en";
3083
+ const out = [];
3084
+ if (extras.missingDeps.length) out.push({
3085
+ severity: "Critique",
3086
+ text: en ? `${extras.missingDeps.length} package${extras.missingDeps.length > 1 ? "s" : ""} imported but absent from package.json: ${extras.missingDeps.map((d) => `\`${d}\``).join(", ")} \u2014 installs will break for everyone else.` : `${extras.missingDeps.length} package${extras.missingDeps.length > 1 ? "s" : ""} import\xE9${extras.missingDeps.length > 1 ? "s" : ""} mais absent${extras.missingDeps.length > 1 ? "s" : ""} de package.json : ${extras.missingDeps.map((d) => `\`${d}\``).join(", ")} \u2014 l\u2019install cassera chez les autres.`
3087
+ });
3088
+ if (extras.brokenEntries.length) out.push({
3089
+ severity: "Critique",
3090
+ text: en ? `package.json points to missing files: ${extras.brokenEntries.map((e) => `\`${e}\``).join(", ")} \u2014 the package is broken for consumers.` : `package.json pointe vers des fichiers absents : ${extras.brokenEntries.map((e) => `\`${e}\``).join(", ")} \u2014 le package est cass\xE9 pour les consommateurs.`
3091
+ });
3092
+ if (extras.sensitive.length) out.push({
3093
+ severity: "Critique",
3094
+ text: en ? `Sensitive file${extras.sensitive.length > 1 ? "s" : ""} in the repo \u2014 e.g. \`${extras.sensitive[0]}\`${git2?.sensitiveTracked.includes(extras.sensitive[0]) ? " (tracked by git \u2014 purge history + rotate secrets)" : ""}. Add to .gitignore.` : `Fichier${extras.sensitive.length > 1 ? "s" : ""} sensible${extras.sensitive.length > 1 ? "s" : ""} dans le d\xE9p\xF4t \u2014 ex. \`${extras.sensitive[0]}\`${git2?.sensitiveTracked.includes(extras.sensitive[0]) ? " (suivi par git \u2014 purger l\u2019historique + r\xE9voquer les secrets)" : ""}. Ajouter au .gitignore.`
3095
+ });
3096
+ if (sec.secret?.length) out.push({
3097
+ severity: "Critique",
3098
+ text: en ? `${sec.secret.length} potential hardcoded secret${sec.secret.length > 1 ? "s" : ""} \u2014 e.g. \`${sec.secret[0].file}:${sec.secret[0].line}\`. Move to env vars, rotate if ever committed.` : `${sec.secret.length} secret${sec.secret.length > 1 ? "s" : ""} potentiellement cod\xE9${sec.secret.length > 1 ? "s" : ""} en dur \u2014 ex. \`${sec.secret[0].file}:${sec.secret[0].line}\`. D\xE9placer en variables d'env, r\xE9voquer si d\xE9j\xE0 commit\xE9.`
3099
+ });
3100
+ if (sec.eval?.length || sec.exec?.length || sec.innerHTML?.length) {
3101
+ const f = [...sec.eval ?? [], ...sec.exec ?? [], ...sec.innerHTML ?? []][0];
3102
+ out.push({
3103
+ severity: "\xC9lev\xE9e",
3104
+ text: en ? `Dangerous sinks detected (eval/exec/innerHTML) \u2014 e.g. \`${f.file}:${f.line}\`. Audit each call site.` : `Sinks dangereux d\xE9tect\xE9s (eval/exec/innerHTML) \u2014 ex. \`${f.file}:${f.line}\`. Auditer chaque site d'appel.`
2598
3105
  });
2599
- return await session.prompt(prompt, {
2600
- temperature: 0.2,
2601
- maxTokens: LOCAL_MAX_TOKENS,
2602
- // Small models degenerate into loops at low temperature penalize
2603
- // repeated tokens so the answer moves forward instead of echoing.
2604
- repeatPenalty: { lastTokens: 128, penalty: 1.2, penalizeNewLine: false }
3106
+ }
3107
+ if (riskFiles.length) out.push({
3108
+ severity: "\xC9lev\xE9e",
3109
+ text: en ? `\`${riskFiles[0].file}\` changes constantly AND is complex (churn ${riskFiles[0].churn}, complexity ${riskFiles[0].score})${extras.untestedRisk.includes(riskFiles[0].file) ? " and has no dedicated test" : ""} \u2014 the classic defect magnet. Cover it with tests before touching it.` : `\`${riskFiles[0].file}\` change sans cesse ET est complexe (churn ${riskFiles[0].churn}, complexit\xE9 ${riskFiles[0].score})${extras.untestedRisk.includes(riskFiles[0].file) ? " et n\u2019a pas de test d\xE9di\xE9" : ""} \u2014 l'aimant \xE0 bugs classique. Couvrir de tests avant d'y toucher.`
3110
+ });
3111
+ if (extras.envUndoc.length) out.push({
3112
+ severity: "Moyenne",
3113
+ text: en ? `${extras.envUndoc.length} env var${extras.envUndoc.length > 1 ? "s" : ""} used but absent from .env.example (e.g. \`${extras.envUndoc[0]}\`) \u2014 document them or setup will break for the next dev.` : `${extras.envUndoc.length} variable${extras.envUndoc.length > 1 ? "s" : ""} d\u2019env utilis\xE9e${extras.envUndoc.length > 1 ? "s" : ""} mais absente${extras.envUndoc.length > 1 ? "s" : ""} de .env.example (ex. \`${extras.envUndoc[0]}\`) \u2014 les documenter sinon le setup cassera pour le prochain dev.`
3114
+ });
3115
+ if (extras.lockDrift.length) out.push({
3116
+ severity: "Moyenne",
3117
+ text: en ? `${extras.lockDrift.length} declared dep${extras.lockDrift.length > 1 ? "s" : ""} absent from the lockfile (${extras.lockDrift.map((d) => `\`${d}\``).join(", ")}) \u2014 run the package manager to resync.` : `${extras.lockDrift.length} d\xE9pendance${extras.lockDrift.length > 1 ? "s" : ""} d\xE9clar\xE9e${extras.lockDrift.length > 1 ? "s" : ""} absente${extras.lockDrift.length > 1 ? "s" : ""} du lockfile (${extras.lockDrift.map((d) => `\`${d}\``).join(", ")}) \u2014 relancer le package manager pour resynchroniser.`
3118
+ });
3119
+ if (extras.tsStrict === false) out.push({
3120
+ severity: "Moyenne",
3121
+ text: en ? "TypeScript `strict` is off \u2014 enable it progressively (`strict: true` or `strictNullChecks` first)." : "Le `strict` TypeScript est d\xE9sactiv\xE9 \u2014 l\u2019activer progressivement (`strict: true` ou `strictNullChecks` d\u2019abord)."
3122
+ });
3123
+ if (extras.deepRel > 3) out.push({
3124
+ severity: "Moyenne",
3125
+ text: en ? `${extras.deepRel} deep relative imports (\`../../..\` 3+ levels) \u2014 expose a public barrel or move the module closer.` : `${extras.deepRel} imports relatifs profonds (\`../../..\` 3+ niveaux) \u2014 exposer un barrel public ou rapprocher le module.`
3126
+ });
3127
+ if (extras.deepNest.length) out.push({
3128
+ severity: "Moyenne",
3129
+ text: en ? `Nesting \u22656 levels in ${extras.deepNest.map((d) => `\`${d}\``).join(", ")} \u2014 early returns / extraction will flatten it.` : `Imbrication \u22656 niveaux dans ${extras.deepNest.map((d) => `\`${d}\``).join(", ")} \u2014 early returns / extraction pour aplatir.`
3130
+ });
3131
+ if (extras.commitConv !== null && extras.commitConv < 50) out.push({
3132
+ severity: "Faible",
3133
+ text: en ? `Only ${extras.commitConv}% of commits are conventional \u2014 a shared format makes history machine-readable.` : `Seulement ${extras.commitConv}% des commits sont conventionnels \u2014 un format partag\xE9 rend l'historique lisible par machine.`
3134
+ });
3135
+ if (extras.deadDeps.length) out.push({
3136
+ severity: "Faible",
3137
+ text: en ? `${extras.deadDeps.length} declared dependenc${extras.deadDeps.length > 1 ? "ies are" : "y is"} never imported (e.g. \`${extras.deadDeps[0]}\`) \u2014 remove to shrink install + audit surface.` : `${extras.deadDeps.length} d\xE9pendance${extras.deadDeps.length > 1 ? "s" : ""} d\xE9clar\xE9e${extras.deadDeps.length > 1 ? "s" : ""} jamais import\xE9e${extras.deadDeps.length > 1 ? "s" : ""} (ex. \`${extras.deadDeps[0]}\`) \u2014 retirer pour r\xE9duire l\u2019install + la surface d\u2019audit.`
3138
+ });
3139
+ if (git2 && git2.fileAuthors.size) {
3140
+ const soloHubs = riskFiles.filter((f) => (git2.fileAuthors.get(f.file)?.size ?? 0) <= 1);
3141
+ const solo = [...git2.fileAuthors.entries()].filter(([, a]) => a.size === 1).length;
3142
+ if (soloHubs.length || git2.churn.size && solo / git2.fileAuthors.size > 0.7) out.push({
3143
+ severity: "Moyenne",
3144
+ text: en ? `Bus factor: ${solo} file${solo > 1 ? "s" : ""} touched by a single author${soloHubs.length ? `, including hot \`${soloHubs[0].file}\`` : ""} \u2014 spread knowledge via reviews/pairing.` : `Bus factor : ${solo} fichier${solo > 1 ? "s" : ""} touch\xE9${solo > 1 ? "s" : ""} par un seul auteur${soloHubs.length ? `, dont le chaud \`${soloHubs[0].file}\`` : ""} \u2014 diffuser la connaissance via reviews/pairing.`
2605
3145
  });
2606
- } finally {
2607
- await context.dispose();
2608
3146
  }
3147
+ if (!infra.some((i) => i.startsWith("CI"))) out.push({
3148
+ severity: "Moyenne",
3149
+ text: en ? "No CI pipeline detected \u2014 add one (tests + typecheck on every push)." : "Aucune CI d\xE9tect\xE9e \u2014 en ajouter une (tests + typecheck \xE0 chaque push)."
3150
+ });
3151
+ if (r.cycles.length) out.push({
3152
+ severity: "Critique",
3153
+ text: en ? `Break ${r.cycles.length} circular dependenc${r.cycles.length > 1 ? "ies" : "y"} \u2014 e.g. \`${r.cycles[0].path[0]}\` \u2194 \`${r.cycles[0].path[1] ?? r.cycles[0].path[0]}\`. Extract the shared contract into a leaf module.` : `Casser ${r.cycles.length} d\xE9pendance${r.cycles.length > 1 ? "s" : ""} circulaire${r.cycles.length > 1 ? "s" : ""} \u2014 ex. \`${r.cycles[0].path[0]}\` \u2194 \`${r.cycles[0].path[1] ?? r.cycles[0].path[0]}\`. Extraire le contrat partag\xE9 dans un module feuille.`
3154
+ });
3155
+ for (const h of r.hotspots.slice(0, 3)) out.push({
3156
+ severity: "\xC9lev\xE9e",
3157
+ text: en ? `Split \`${h.file}\` (complexity ${h.score}) \u2014 extract independent blocks into focused modules.` : `D\xE9couper \`${h.file}\` (complexit\xE9 ${h.score}) \u2014 extraire les blocs ind\xE9pendants dans des modules cibl\xE9s.`
3158
+ });
3159
+ if (r.duplicates.length) out.push({
3160
+ severity: "Moyenne",
3161
+ text: en ? `Factor ${r.duplicates.length} duplicated block${r.duplicates.length > 1 ? "s" : ""} \u2014 e.g. ${r.duplicates[0].files.map((f) => `\`${f}\``).join(" / ")} share ${r.duplicates[0].lines} identical lines.` : `Factoriser ${r.duplicates.length} bloc${r.duplicates.length > 1 ? "s" : ""} dupliqu\xE9${r.duplicates.length > 1 ? "s" : ""} \u2014 ex. ${r.duplicates[0].files.map((f) => `\`${f}\``).join(" / ")} partagent ${r.duplicates[0].lines} lignes identiques.`
3162
+ });
3163
+ if (r.unusedFiles.length) out.push({
3164
+ severity: "Moyenne",
3165
+ text: en ? `Review ${r.unusedFiles.length} unreferenced file${r.unusedFiles.length > 1 ? "s" : ""} \u2014 delete or wire them in (e.g. \`${r.unusedFiles[0]}\`).` : `V\xE9rifier ${r.unusedFiles.length} fichier${r.unusedFiles.length > 1 ? "s" : ""} non r\xE9f\xE9renc\xE9${r.unusedFiles.length > 1 ? "s" : ""} \u2014 supprimer ou brancher (ex. \`${r.unusedFiles[0]}\`).`
3166
+ });
3167
+ if (r.unusedExports.length > 5) out.push({
3168
+ severity: "Faible",
3169
+ text: en ? `Prune ${r.unusedExports.length} exports nobody imports \u2014 shrink the public surface.` : `Nettoyer ${r.unusedExports.length} exports que personne n'importe \u2014 r\xE9duire la surface publique.`
3170
+ });
3171
+ if (!hasTests) out.push({
3172
+ severity: "\xC9lev\xE9e",
3173
+ text: en ? "No test files detected \u2014 add a test suite before refactoring." : "Aucun fichier de test d\xE9tect\xE9 \u2014 ajouter une suite de tests avant de refactorer."
3174
+ });
3175
+ if (smells.console && smells.console.length > 5) out.push({
3176
+ severity: "Faible",
3177
+ text: en ? `${smells.console.length} console.* calls in production code \u2014 route through a logger (e.g. \`${smells.console[0].file}:${smells.console[0].line}\`).` : `${smells.console.length} appels console.* dans le code de prod \u2014 passer par un logger (ex. \`${smells.console[0].file}:${smells.console[0].line}\`).`
3178
+ });
3179
+ if (smells.todo && smells.todo.length > 5) out.push({
3180
+ severity: "Faible",
3181
+ text: en ? `${smells.todo.length} TODO/FIXME markers \u2014 triage into tracked issues.` : `${smells.todo.length} marqueurs TODO/FIXME \u2014 trier en tickets suivis.`
3182
+ });
3183
+ if (!out.length) out.push({
3184
+ severity: "Faible",
3185
+ text: en ? "Nothing structural to fix \u2014 keep the hygiene rules that got this score." : "Rien de structurel \xE0 corriger \u2014 garder les r\xE8gles d\u2019hygi\xE8ne qui ont produit ce score."
3186
+ });
3187
+ return out;
3188
+ }
3189
+ async function buildDeterministicReport(projectPath, lang = "fr") {
3190
+ const en = lang === "en";
3191
+ const [index, graph, health] = await Promise.all([
3192
+ getIndex(projectPath),
3193
+ collectImportGraph(projectPath),
3194
+ analyzeProject(projectPath)
3195
+ ]);
3196
+ const name = basename3(health.projectPath);
3197
+ let pkg = {};
3198
+ try {
3199
+ pkg = JSON.parse(await readFile6(join8(health.projectPath, "package.json"), "utf8"));
3200
+ } catch {
3201
+ }
3202
+ const langCount = /* @__PURE__ */ new Map();
3203
+ for (const rel of Object.keys(index.files)) {
3204
+ const l = EXT_LANG[extname3(rel).toLowerCase()];
3205
+ if (!l) continue;
3206
+ langCount.set(l, (langCount.get(l) ?? 0) + 1);
3207
+ }
3208
+ const langs = [...langCount.entries()].sort((a, b) => b[1] - a[1]);
3209
+ const deps = Object.keys(pkg.dependencies ?? {});
3210
+ const devDeps = Object.keys(pkg.devDependencies ?? {});
3211
+ const scripts = Object.keys(pkg.scripts ?? {});
3212
+ const symbols = Object.values(index.files).reduce((s, f) => s + f.chunks.filter((c) => c.name).length, 0);
3213
+ const hubs = [...graph.inDegree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
3214
+ const entryPoints = graph.codeFiles.filter((f) => looksLikeEntry(f, pkg)).slice(0, 10);
3215
+ const leaves = graph.codeFiles.filter((f) => !graph.edges.some((e) => e.from === f)).length;
3216
+ const testFiles = Object.keys(index.files).filter((f) => /test|spec|__tests__/i.test(f));
3217
+ const srcFiles = graph.codeFiles.filter((f) => !/test|spec|__tests__/i.test(f));
3218
+ const testRatio = srcFiles.length ? Math.round(testFiles.length / srcFiles.length * 100) : 0;
3219
+ const largest = graph.codeFiles.map((f) => ({ f, lines: graph.fileTexts.get(f).split("\n").length })).sort((a, b) => b.lines - a.lines).slice(0, 5);
3220
+ const indexPaths = new Set(Object.keys(index.files).map((f) => f.toLowerCase()));
3221
+ const docs = ["readme.md", "license", "license.md", "changelog.md", "contributing.md", "security.md", "agents.md"].filter((d) => indexPaths.has(d));
3222
+ const smells = scanCode(graph.fileTexts, SMELL_PATS);
3223
+ const sec = scanCode(graph.fileTexts, SEC_PATS, 5);
3224
+ const hasTests = testFiles.length > 0;
3225
+ const git2 = await gitActivity(health.projectPath);
3226
+ const infra = detectInfra(indexPaths);
3227
+ const env = await envAudit(health.projectPath, graph.fileTexts);
3228
+ const imported = importedPackages(graph.fileTexts);
3229
+ const deadDeps = unusedDeps(deps, imported);
3230
+ const missing = await missingDeps(health.projectPath, graph.fileTexts, pkg, indexPaths);
3231
+ const lockDrift = await lockfileDrift(health.projectPath, deps);
3232
+ const fnComplex = functionComplexity(index);
3233
+ const dupNames = duplicateNames(graph.codeFiles);
3234
+ const asyncNoAwait = asyncWithoutAwait(index);
3235
+ const maxDepth = graphDepth(graph.edges, entryPoints);
3236
+ const cfg = await configAudit(health.projectPath, pkg, indexPaths, !!git2);
3237
+ const longFns = functionHotspots(index);
3238
+ const brokenEntries = await brokenPkgEntries(health.projectPath, pkg);
3239
+ const deepRel = deepImports(graph.fileTexts);
3240
+ const shape = codeShape(graph.fileTexts);
3241
+ const readme = await readmeAudit(health.projectPath);
3242
+ const commitQ = git2 ? commitQuality(git2.subjects) : null;
3243
+ const typedFiles = graph.codeFiles.filter((f) => /\.(ts|tsx)$/.test(f)).length;
3244
+ const typedPct = graph.codeFiles.length ? Math.round(typedFiles / graph.codeFiles.length * 100) : 0;
3245
+ const staleHubs = git2 ? hubs.filter(([f]) => git2.fileLastCommit.has(f)).map(([f, n]) => ({ f, n, last: git2.fileLastCommit.get(f) })).sort((a, b) => a.last.localeCompare(b.last)).slice(0, 5) : [];
3246
+ const testBases = new Set(testFiles.map((f) => basename3(f).replace(/\.(test|spec)\.[^.]+$/i, "").toLowerCase()));
3247
+ const sensitive = [...git2?.sensitiveTracked ?? []];
3248
+ if (!git2) {
3249
+ for (const rel of [".env", ".env.local", ".env.production"]) {
3250
+ try {
3251
+ await access(join8(health.projectPath, rel));
3252
+ sensitive.push(rel);
3253
+ } catch {
3254
+ }
3255
+ }
3256
+ }
3257
+ const docCov = docCoverage(graph.fileTexts);
3258
+ const docPct = docCov.total ? Math.round(docCov.documented / docCov.total * 100) : 0;
3259
+ const complexityByFile = new Map(health.hotspots.map((h) => [h.file, h.score]));
3260
+ const riskFiles = git2 ? [...git2.churn.entries()].filter(([f]) => complexityByFile.has(f)).map(([file, churn]) => ({ file, churn, score: complexityByFile.get(file) })).sort((a, b) => b.churn * b.score - a.churn * a.score).slice(0, 5) : [];
3261
+ const untestedRisk = riskFiles.filter((r) => !testBases.has(basename3(r.file).replace(/\.[^.]+$/, "").toLowerCase()));
3262
+ const topChurn = git2 ? [...git2.churn.entries()].filter(([f]) => !/lock|\.min\.|dist\/|generated/i.test(f)).sort((a, b) => b[1] - a[1]).slice(0, 8) : [];
3263
+ const t2 = en ? {
3264
+ title: "Deterministic report",
3265
+ genBy: "generated by static analysis \u2014 no LLM, no network",
3266
+ summary: "Executive summary",
3267
+ stack: "Stack & structure",
3268
+ lang: "Languages",
3269
+ deps: "Runtime deps",
3270
+ devDeps: "Dev deps",
3271
+ scripts: "Scripts",
3272
+ arch: "Module graph",
3273
+ hubs: "Hub modules (most imported)",
3274
+ entries: "Entry points",
3275
+ leaves: "leaf modules",
3276
+ syms: "symbols extracted",
3277
+ constraints: "Product constraints",
3278
+ none: "None declared",
3279
+ debt: "Debt & smells",
3280
+ secu: "Security signals",
3281
+ secuNone: "No risky pattern detected in scanned code.",
3282
+ tests: "test/src file ratio",
3283
+ largest: "Largest files",
3284
+ docs: "Docs present",
3285
+ infra: "Infra detected",
3286
+ gitTitle: "Git activity & risk",
3287
+ gitCommits: "commits",
3288
+ gitAuthors: "authors",
3289
+ gitLast: "last commit",
3290
+ gitChurn: "Most churned files",
3291
+ gitRisk: "Risk hotspots (churn \xD7 complexity)",
3292
+ gitSolo: "single-author files",
3293
+ docCov: "docstring coverage",
3294
+ timeline: "Activity (commits/month)",
3295
+ longestFns: "Longest functions",
3296
+ untested: "Untested risk hotspots",
3297
+ envUsed: "Env vars used",
3298
+ envUndoc: "not documented in .env.example",
3299
+ deadDeps: "Dependencies never imported",
3300
+ sensitive: "Sensitive files present",
3301
+ cfg: "Config hygiene",
3302
+ cfgStrict: "tsconfig strict: off",
3303
+ cfgGitignore: "no .gitignore",
3304
+ cfgPkg: "package.json missing",
3305
+ pkgBroken: "broken package entries",
3306
+ typed: "typed files",
3307
+ comments: "comment density",
3308
+ deepImports: "Deep relative imports (3+ levels)",
3309
+ deepNest: "Deep nesting (6+ levels)",
3310
+ readmeTitle: "README audit",
3311
+ readmeInstall: "install section",
3312
+ readmeUsage: "usage section",
3313
+ readmeCode: "code blocks",
3314
+ readmeBadges: "badges",
3315
+ commitQ: "Commit messages",
3316
+ commitConv: "conventional",
3317
+ staleHubs: "Stable core (hubs untouched longest)",
3318
+ missingDeps: "imported but undeclared",
3319
+ lockDrift: "absent from lockfile",
3320
+ bigCommits: "Largest commits",
3321
+ fnComplex: "Most complex functions",
3322
+ dupNames: "Duplicate file names",
3323
+ asyncNoAwait: "async without await",
3324
+ graphDepth: "Max import chain depth",
3325
+ reco: "Recommendations",
3326
+ sev: "Severity",
3327
+ action: "Action",
3328
+ labels: {
3329
+ todo: "TODO/FIXME markers",
3330
+ console: "console.* calls",
3331
+ tsIgnore: "@ts-ignore/-expect-error",
3332
+ any: "`any` types",
3333
+ emptyCatch: "empty catch blocks",
3334
+ debugger: "debugger statements",
3335
+ secret: "hardcoded secrets (suspected)",
3336
+ eval: "eval / new Function",
3337
+ exec: "child_process / execSync",
3338
+ innerHTML: "innerHTML assignments",
3339
+ unsafeRegex: "dynamic RegExp"
3340
+ },
3341
+ verdict: (g) => ({ A: "Excellent health \u2014 clean structure.", B: "Good health \u2014 minor debt.", C: "Correct \u2014 visible debt to watch.", D: "Fragile \u2014 refactor before growing.", E: "Critical \u2014 structural debt blocking." })[g] ?? ""
3342
+ } : {
3343
+ title: "Rapport d\xE9terministe",
3344
+ genBy: "g\xE9n\xE9r\xE9 par analyse statique \u2014 aucun LLM, aucun r\xE9seau",
3345
+ summary: "R\xE9sum\xE9 ex\xE9cutif",
3346
+ stack: "Stack & structure",
3347
+ lang: "Langages",
3348
+ deps: "D\xE9pendances runtime",
3349
+ devDeps: "D\xE9pendances dev",
3350
+ scripts: "Scripts",
3351
+ arch: "Graphe de modules",
3352
+ hubs: "Modules hubs (les plus import\xE9s)",
3353
+ entries: "Points d\u2019entr\xE9e",
3354
+ leaves: "modules feuilles",
3355
+ syms: "symboles extraits",
3356
+ constraints: "Contraintes produit",
3357
+ none: "Aucune d\xE9clar\xE9e",
3358
+ debt: "Dette & smells",
3359
+ secu: "Signaux s\xE9curit\xE9",
3360
+ secuNone: "Aucun pattern risqu\xE9 d\xE9tect\xE9 dans le code scann\xE9.",
3361
+ tests: "ratio tests/src",
3362
+ largest: "Plus gros fichiers",
3363
+ docs: "Docs pr\xE9sentes",
3364
+ infra: "Infra d\xE9tect\xE9e",
3365
+ gitTitle: "Activit\xE9 Git & risque",
3366
+ gitCommits: "commits",
3367
+ gitAuthors: "auteurs",
3368
+ gitLast: "dernier commit",
3369
+ gitChurn: "Fichiers les plus modifi\xE9s",
3370
+ gitRisk: "Hotspots de risque (churn \xD7 complexit\xE9)",
3371
+ gitSolo: "fichiers mono-auteur",
3372
+ docCov: "couverture docstrings",
3373
+ timeline: "Activit\xE9 (commits/mois)",
3374
+ longestFns: "Fonctions les plus longues",
3375
+ untested: "Hotspots \xE0 risque non test\xE9s",
3376
+ envUsed: "Variables d\u2019env utilis\xE9es",
3377
+ envUndoc: "non document\xE9es dans .env.example",
3378
+ deadDeps: "D\xE9pendances jamais import\xE9es",
3379
+ sensitive: "Fichiers sensibles pr\xE9sents",
3380
+ cfg: "Hygi\xE8ne de config",
3381
+ cfgStrict: "tsconfig strict : off",
3382
+ cfgGitignore: "pas de .gitignore",
3383
+ cfgPkg: "package.json incomplet",
3384
+ pkgBroken: "entr\xE9es package cass\xE9es",
3385
+ typed: "fichiers typ\xE9s",
3386
+ comments: "densit\xE9 de commentaires",
3387
+ deepImports: "Imports relatifs profonds (3+ niveaux)",
3388
+ deepNest: "Imbrication profonde (6+ niveaux)",
3389
+ readmeTitle: "Audit README",
3390
+ readmeInstall: "section install",
3391
+ readmeUsage: "section usage",
3392
+ readmeCode: "blocs de code",
3393
+ readmeBadges: "badges",
3394
+ commitQ: "Messages de commit",
3395
+ commitConv: "conventionnels",
3396
+ staleHubs: "Noyau stable (hubs les plus anciens)",
3397
+ missingDeps: "import\xE9s mais non d\xE9clar\xE9s",
3398
+ lockDrift: "absentes du lockfile",
3399
+ bigCommits: "Plus gros commits",
3400
+ fnComplex: "Fonctions les plus complexes",
3401
+ dupNames: "Noms de fichiers dupliqu\xE9s",
3402
+ asyncNoAwait: "async sans await",
3403
+ graphDepth: "Profondeur max des cha\xEEnes d\u2019imports",
3404
+ reco: "Recommandations",
3405
+ sev: "S\xE9v\xE9rit\xE9",
3406
+ action: "Action",
3407
+ labels: {
3408
+ todo: "Marqueurs TODO/FIXME",
3409
+ console: "Appels console.*",
3410
+ tsIgnore: "@ts-ignore/-expect-error",
3411
+ any: "Types `any`",
3412
+ emptyCatch: "catch vides",
3413
+ debugger: "Instructions debugger",
3414
+ secret: "Secrets en dur (suspect\xE9s)",
3415
+ eval: "eval / new Function",
3416
+ exec: "child_process / execSync",
3417
+ innerHTML: "Affectations innerHTML",
3418
+ unsafeRegex: "RegExp dynamiques"
3419
+ },
3420
+ verdict: (g) => ({ A: "Excellente sant\xE9 \u2014 structure propre.", B: "Bonne sant\xE9 \u2014 dette mineure.", C: "Correct \u2014 dette visible \xE0 surveiller.", D: "Fragile \u2014 refactorer avant de grossir.", E: "Critique \u2014 dette structurelle bloquante." })[g] ?? ""
3421
+ };
3422
+ const out = [];
3423
+ out.push(`# \u{1F4CA} ${t2.title} \u2014 \`${name}\``);
3424
+ out.push(`_${t2.genBy}_`, "");
3425
+ out.push(`## 1. ${t2.summary}`);
3426
+ out.push(`**${bar(health.score)} ${health.score}/100 (${health.grade})** \u2014 ${t2.verdict(health.grade)}`);
3427
+ out.push("");
3428
+ out.push(`- ${health.analyzedFiles} ${en ? "code files" : "fichiers de code"} \xB7 ${symbols} ${t2.syms} \xB7 ${health.importEdges} ${en ? "local imports" : "imports locaux"} \xB7 ${leaves} ${t2.leaves}`);
3429
+ out.push(`- ${testFiles.length} ${en ? "test files" : "fichiers de test"} (${testRatio}% ${t2.tests}) \xB7 ${countHits(smells)} ${en ? "smell hits" : "smells d\xE9tect\xE9s"} \xB7 ${countHits(sec)} ${en ? "security signals" : "signaux s\xE9curit\xE9"}`);
3430
+ out.push(`- ${docCov.documented}/${docCov.total} ${en ? "exports documented" : "exports document\xE9s"} (${docPct}% ${t2.docCov})`);
3431
+ if (git2) out.push(`- ${git2.commits} ${t2.gitCommits} \xB7 ${git2.authors.size} ${en ? "author(s)" : "auteur(s)"} \xB7 ${t2.gitLast} : ${git2.lastDate}`);
3432
+ out.push("");
3433
+ out.push(`## 2. ${t2.stack}`);
3434
+ if (pkg.name) out.push(`- **${en ? "Package" : "Package"}** : \`${pkg.name}${pkg.version ? `@${pkg.version}` : ""}\``);
3435
+ if (langs.length) out.push(`- **${t2.lang}** : ${langs.map(([l, n]) => `${l} (${n})`).join(", ")}`);
3436
+ if (deps.length) out.push(`- **${t2.deps}** (${deps.length}) : ${deps.slice(0, 12).map((d) => `\`${d}\``).join(", ")}${deps.length > 12 ? " \u2026" : ""}`);
3437
+ if (devDeps.length) out.push(`- **${t2.devDeps}** (${devDeps.length}) : ${devDeps.slice(0, 8).map((d) => `\`${d}\``).join(", ")}${devDeps.length > 8 ? " \u2026" : ""}`);
3438
+ if (scripts.length) out.push(`- **${t2.scripts}** : ${scripts.map((s) => `\`${s}\``).join(", ")}`);
3439
+ if (docs.length) out.push(`- **${t2.docs}** : ${docs.map((d) => `\`${d}\``).join(", ")}`);
3440
+ if (infra.length) out.push(`- **${t2.infra}** : ${infra.map((i) => `\`${i}\``).join(", ")}`);
3441
+ if (env.used.length) out.push(`- **${t2.envUsed}** (${env.used.length}) : ${env.used.slice(0, 10).map((v) => `\`${v}\``).join(", ")}${env.used.length > 10 ? " \u2026" : ""}`);
3442
+ if (env.undocumented.length && env.hasTemplate) out.push(` - \u26A0\uFE0F ${env.undocumented.length} ${t2.envUndoc} : ${env.undocumented.slice(0, 8).map((v) => `\`${v}\``).join(", ")}`);
3443
+ if (deadDeps.length) out.push(`- **${t2.deadDeps}** : ${deadDeps.map((d) => `\`${d}\``).join(", ")}`);
3444
+ if (missing.length) out.push(`- \u26A0\uFE0F **${en ? "Deps" : "Deps"} ${t2.missingDeps}** : ${missing.map((d) => `\`${d}\``).join(", ")}`);
3445
+ if (lockDrift.length) out.push(`- \u26A0\uFE0F ${lockDrift.length} ${en ? "deps" : "deps"} ${t2.lockDrift} : ${lockDrift.map((d) => `\`${d}\``).join(", ")}`);
3446
+ const cfgNotes = [];
3447
+ if (cfg.tsStrict === false) cfgNotes.push(t2.cfgStrict);
3448
+ if (cfg.isGit && !cfg.gitignore) cfgNotes.push(t2.cfgGitignore);
3449
+ if (cfg.pkgMissing.length) cfgNotes.push(`${t2.cfgPkg} : ${cfg.pkgMissing.map((k) => `\`${k}\``).join(", ")}`);
3450
+ if (cfgNotes.length) out.push(`- **${t2.cfg}** : ${cfgNotes.join(" \xB7 ")}`);
3451
+ if (brokenEntries.length) out.push(`- \u26A0\uFE0F **${t2.pkgBroken}** : ${brokenEntries.map((e) => `\`${e}\``).join(", ")}`);
3452
+ out.push(`- ${typedPct}% ${t2.typed} (${typedFiles}/${graph.codeFiles.length}) \xB7 ${shape.commentPct}% ${t2.comments}`);
3453
+ if (readme) {
3454
+ const ok = (b) => b ? "\u2705" : "\u274C";
3455
+ out.push(`- **${t2.readmeTitle}** : ${t2.readmeInstall} ${ok(readme.install)} \xB7 ${t2.readmeUsage} ${ok(readme.usage)} \xB7 ${readme.codeBlocks} ${t2.readmeCode} \xB7 ${readme.badges} ${t2.readmeBadges}`);
3456
+ }
3457
+ out.push("");
3458
+ out.push(`## 3. ${t2.arch}`);
3459
+ if (hubs.length) {
3460
+ out.push(`**${t2.hubs}** :`, "");
3461
+ for (const [f, n] of hubs) out.push(`- \`${f}\` \u2190 ${n} ${en ? "importers" : "importeurs"}`);
3462
+ out.push("");
3463
+ }
3464
+ if (entryPoints.length) {
3465
+ out.push(`**${t2.entries}** : ${entryPoints.map((f) => `\`${f}\``).join(", ")}`, "");
3466
+ }
3467
+ if (largest.length) {
3468
+ out.push(`**${t2.largest}** : ${largest.map((x) => `\`${x.f}\` (${x.lines}l)`).join(", ")}`, "");
3469
+ }
3470
+ if (longFns.length) {
3471
+ out.push(`**${t2.longestFns}** :`, "");
3472
+ for (const f of longFns) out.push(`- \`${f.file}\` \u2192 \`${f.name}\` (${f.lines}l)`);
3473
+ out.push("");
3474
+ }
3475
+ if (deepRel.length) {
3476
+ out.push(`**${t2.deepImports}** \u2014 ${deepRel.length} :`, "");
3477
+ for (const d of deepRel.slice(0, 5)) out.push(`- \`${d.file}:${d.line}\` \u2192 \`${d.sample}\``);
3478
+ out.push("");
3479
+ }
3480
+ if (shape.deepNest.length) {
3481
+ out.push(`**${t2.deepNest}** : ${shape.deepNest.map((d) => `\`${d.file}\` (${d.depth})`).join(", ")}`, "");
3482
+ }
3483
+ if (maxDepth > 0) out.push(`- **${t2.graphDepth}** : ${maxDepth}`);
3484
+ if (fnComplex.length) {
3485
+ out.push(`**${t2.fnComplex}** :`, "");
3486
+ for (const f of fnComplex) out.push(`- \`${f.file}\` \u2192 \`${f.name}\` (${f.score} ${en ? "branches" : "branchements"})`);
3487
+ out.push("");
3488
+ }
3489
+ if (dupNames.length) {
3490
+ out.push(`**${t2.dupNames}** :`, "");
3491
+ for (const d of dupNames) out.push(`- \`${d.name}\` \u2192 ${d.files.map((f) => `\`${f}\``).join(", ")}`);
3492
+ out.push("");
3493
+ }
3494
+ if (git2) {
3495
+ out.push(`## 4. ${t2.gitTitle}`, "");
3496
+ const topAuthors = [...git2.authors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([a, n]) => `${a} (${n})`).join(", ");
3497
+ const soloCount = [...git2.fileAuthors.values()].filter((a) => a.size === 1).length;
3498
+ out.push(`- ${git2.commits} ${t2.gitCommits} \xB7 **${t2.gitAuthors}** : ${topAuthors}`);
3499
+ out.push(`- ${soloCount} ${t2.gitSolo}`);
3500
+ if (commitQ) out.push(`- ${t2.commitQ} : ${commitQ.conventionalPct}% ${t2.commitConv} \xB7 ~${commitQ.avgLen} ${en ? "chars" : "car."}`);
3501
+ out.push("");
3502
+ out.push(`**${t2.gitChurn}** :`, "");
3503
+ for (const [f, c] of topChurn) {
3504
+ const n = git2.fileAuthors.get(f)?.size ?? 0;
3505
+ out.push(`- \`${f}\` \u2014 ${c} ${en ? "lines changed" : "lignes modifi\xE9es"} \xB7 ${n} ${en ? "author(s)" : "auteur(s)"}`);
3506
+ }
3507
+ out.push("");
3508
+ if (riskFiles.length) {
3509
+ out.push(`**${t2.gitRisk}** :`, "");
3510
+ for (const r of riskFiles) {
3511
+ const tested = testBases.has(basename3(r.file).replace(/\.[^.]+$/, "").toLowerCase());
3512
+ out.push(`- \`${r.file}\` \u2014 churn ${r.churn} \xD7 ${en ? "complexity" : "complexit\xE9"} ${r.score}${tested ? "" : en ? " \xB7 \u26A0\uFE0F no test" : " \xB7 \u26A0\uFE0F sans test"}`);
3513
+ }
3514
+ out.push("");
3515
+ if (untestedRisk.length) out.push(`_${t2.untested} : ${untestedRisk.map((r) => `\`${r.file}\``).join(", ")}_`, "");
3516
+ }
3517
+ if (git2.months.size > 1) {
3518
+ const months = [...git2.months.entries()].sort().slice(-12);
3519
+ const max = Math.max(...months.map(([, n]) => n));
3520
+ out.push(`**${t2.timeline}** :`, "");
3521
+ out.push("```");
3522
+ for (const [m, n] of months) out.push(`${m} ${"\u2587".repeat(Math.max(1, Math.round(n / max * 20)))} ${n}`);
3523
+ out.push("```", "");
3524
+ }
3525
+ if (staleHubs.length) {
3526
+ out.push(`**${t2.staleHubs}** :`, "");
3527
+ for (const s of staleHubs) out.push(`- \`${s.f}\` \u2190 ${s.n} ${en ? "importers" : "importeurs"} \xB7 ${en ? "last change" : "derni\xE8re modif"} ${s.last}`);
3528
+ out.push("");
3529
+ }
3530
+ if (git2.commitSizes.length) {
3531
+ const big = [...git2.commitSizes].sort((a, b) => b.lines - a.lines).slice(0, 3);
3532
+ out.push(`**${t2.bigCommits}** : ${big.map((c) => `${c.lines} ${en ? "lines" : "lignes"} / ${c.files} ${en ? "files" : "fichiers"}`).join(" \xB7 ")}`, "");
3533
+ }
3534
+ }
3535
+ out.push(`## 5. ${t2.debt}`);
3536
+ const smellKeys = Object.keys(smells);
3537
+ if (!smellKeys.length) out.push(en ? "_Nothing detected._" : "_Rien d\xE9tect\xE9._");
3538
+ for (const key of smellKeys) {
3539
+ const hits = smells[key];
3540
+ out.push(`- **${t2.labels[key] ?? key}** \u2014 ${hits.length}${en ? " hit" + (hits.length > 1 ? "s" : "") : ""}`);
3541
+ for (const h of hits.slice(0, 4)) out.push(` - \`${h.file}:${h.line}\` \u2014 ${h.sample}`);
3542
+ if (hits.length > 4) out.push(` - _\u2026${hits.length - 4} ${en ? "more" : "autres"}_`);
3543
+ }
3544
+ if (asyncNoAwait.length) {
3545
+ out.push(`- **${t2.asyncNoAwait}** \u2014 ${asyncNoAwait.length}`);
3546
+ for (const a of asyncNoAwait.slice(0, 5)) out.push(` - \`${a.file}\` \u2192 \`${a.name}\``);
3547
+ }
3548
+ out.push("");
3549
+ out.push(`## 6. ${t2.secu}`);
3550
+ if (sensitive.length) {
3551
+ out.push(`- **${t2.sensitive}** \u2014 ${sensitive.length}`);
3552
+ for (const f of sensitive.slice(0, 6)) out.push(` - \`${f}\`${git2?.sensitiveTracked.includes(f) ? en ? " (tracked by git!)" : " (suivi par git !)" : ""}`);
3553
+ }
3554
+ const secKeys = Object.keys(sec);
3555
+ if (!secKeys.length && !sensitive.length) out.push(`_${t2.secuNone}_`);
3556
+ for (const key of secKeys) {
3557
+ const hits = sec[key];
3558
+ out.push(`- **${t2.labels[key] ?? key}** \u2014 ${hits.length}`);
3559
+ for (const h of hits.slice(0, 5)) out.push(` - \`${h.file}:${h.line}\` \u2014 ${h.sample}`);
3560
+ if (hits.length > 5) out.push(` - _\u2026${hits.length - 5} ${en ? "more" : "autres"}_`);
3561
+ }
3562
+ out.push("");
3563
+ out.push(`## 7. ${t2.constraints}`);
3564
+ out.push(index.constraints.length ? index.constraints.map((c) => `- ${c}`).join("\n") : t2.none, "");
3565
+ out.push(formatHealthReportMd(health, lang).replace(/^## /, "## 8. ").replace(/\n### /g, "\n#### "), "");
3566
+ out.push(`## 9. ${t2.reco}`, "");
3567
+ out.push(`| ${t2.sev} | ${t2.action} |`, "|---|---|");
3568
+ const SEV_ICON = { Critique: "\u{1F534}", "\xC9lev\xE9e": "\u{1F7E0}", Moyenne: "\u{1F7E1}", Faible: "\u{1F535}" };
3569
+ for (const r of recommendations(health, hasTests, smells, sec, git2, infra, riskFiles, { sensitive, envUndoc: env.undocumented, deadDeps, tsStrict: cfg.tsStrict, untestedRisk: untestedRisk.map((r2) => r2.file), brokenEntries, deepRel: deepRel.length, deepNest: shape.deepNest.map((d) => d.file), commitConv: commitQ?.conventionalPct ?? null, missingDeps: missing, lockDrift }, lang)) out.push(`| ${SEV_ICON[r.severity]} ${r.severity} | ${r.text} |`);
3570
+ out.push("");
3571
+ out.push("---");
3572
+ out.push(`_${en ? "Made with passion by shinzarou-eng" : "Fait avec passion par shinzarou-eng"} \u2014 dsh-codebase-chat \xB7 ${en ? "deterministic mode" : "mode d\xE9terministe"}_`);
3573
+ return out.join("\n");
2609
3574
  }
2610
3575
 
2611
3576
  // src/cli.ts
@@ -2659,7 +3624,7 @@ Usage:
2659
3624
  npx dsh-codebase-chat --project <path> --watch
2660
3625
  npx dsh-codebase-chat --project <path> --prompt intelligence
2661
3626
  npx dsh-codebase-chat --project <path> --prompt intelligence --call # answered via DEEPSEEK_API_KEY
2662
- npx dsh-codebase-chat --project <path> --prompt intelligence --local # answered 100% offline
3627
+ npx dsh-codebase-chat --project <path> --prompt intelligence --no-llm # deterministic report, zero model
2663
3628
 
2664
3629
  Options:
2665
3630
  -p, --project <path> Project directory (default: current directory)
@@ -2680,8 +3645,8 @@ Options:
2680
3645
  --call With --prompt: send it to the API (needs DEEPSEEK_API_KEY
2681
3646
  or OPENAI_API_KEY; DEEPSEEK_BASE_URL / CODEBASE_MODEL
2682
3647
  customize endpoint/model) instead of printing it.
2683
- --local With --prompt: answer with the embedded local model
2684
- (node-llama-cpp, ~1 GB download on first use, offline).
3648
+ --no-llm With --prompt: deterministic full report \u2014 pure static
3649
+ analysis, no model, no key, no network.
2685
3650
  -w, --watch Keep the index hot \u2014 rebuild incrementally on file changes
2686
3651
  -e, --embed Enable local semantic embeddings (slower, more relevant)
2687
3652
  --lang <en|fr> Language for headings (default: .codebase-chat.json lang, else fr)
@@ -2711,7 +3676,7 @@ async function main() {
2711
3676
  focus: { type: "string" },
2712
3677
  style: { type: "string" },
2713
3678
  call: { type: "boolean", default: false },
2714
- local: { type: "boolean", default: false },
3679
+ "no-llm": { type: "boolean", default: false },
2715
3680
  watch: { type: "boolean", short: "w", default: false },
2716
3681
  embed: { type: "boolean", short: "e", default: false },
2717
3682
  lang: { type: "string" },
@@ -2809,7 +3774,11 @@ async function main() {
2809
3774
  console.error(lang === "en" ? `--prompt ${mode} needs a query: add --ask/--search/--file (or --focus)` : `--prompt ${mode} n\xE9cessite une requ\xEAte : ajoute --ask/--search/--file (ou --focus)`);
2810
3775
  exit(1);
2811
3776
  }
2812
- const useLocal = values.local || isLocalLlmEnabled();
3777
+ if (values["no-llm"]) {
3778
+ const report = await buildDeterministicReport(values.project, lang);
3779
+ console.log(process.stdout.isTTY ? renderAnswerTerminal(report) : report);
3780
+ exit(0);
3781
+ }
2813
3782
  const result = await buildContext({
2814
3783
  project: values.project,
2815
3784
  query,
@@ -2817,8 +3786,7 @@ async function main() {
2817
3786
  filePath: values.file,
2818
3787
  lang,
2819
3788
  embed: values.embed,
2820
- diff: values.diff,
2821
- maxTokens: useLocal ? 3500 : void 0
3789
+ diff: values.diff
2822
3790
  });
2823
3791
  let staticSection = "";
2824
3792
  if ((/* @__PURE__ */ new Set(["intelligence", "report", "audit", "tasks", "ceo"])).has(mode)) {
@@ -2831,14 +3799,10 @@ ${formatHealthReport(report, lang)}`;
2831
3799
  } catch {
2832
3800
  }
2833
3801
  }
2834
- const projectName = basename3(result.absProject);
3802
+ const projectName = basename4(result.absProject);
2835
3803
  let prompt;
2836
3804
  try {
2837
- prompt = useLocal ? `${result.context}${staticSection}
2838
-
2839
- ${lang === "en" ? `Answer based only on the codebase context above (${mode} mode). Structure your answer in short sections with bullet points, and cite each fact as [source: path:line].${query ? ` Question: ${query}` : ""}` : `R\xE9ponds en t'appuyant uniquement sur le contexte du codebase ci-dessus (mode ${mode}). Structure ta r\xE9ponse en sections courtes avec des puces, et cite chaque fait avec [source: fichier:ligne].${query ? ` Question : ${query}` : ""}`}
2840
-
2841
- ${lang === "en" ? "Answer" : "R\xE9ponse"} :` : buildToolPrompt(tool, {
3805
+ prompt = buildToolPrompt(tool, {
2842
3806
  context: `${result.context}${staticSection}`,
2843
3807
  projectName,
2844
3808
  lang,
@@ -2852,12 +3816,6 @@ ${lang === "en" ? "Answer" : "R\xE9ponse"} :` : buildToolPrompt(tool, {
2852
3816
  console.error(lang === "en" ? `unknown prompt mode "${mode}" \u2014 expected: intelligence, report, audit, tasks, ceo, player, chat, search, explain, refactor, crea` : `mode de prompt inconnu "${mode}" \u2014 attendu : intelligence, report, audit, tasks, ceo, player, chat, search, explain, refactor, crea`);
2853
3817
  exit(1);
2854
3818
  }
2855
- if (values.local || isLocalLlmEnabled()) {
2856
- console.error(lang === "en" ? "Answering with the embedded local model (first run downloads ~1 GB)..." : "R\xE9ponse via le mod\xE8le local embarqu\xE9 (premier lancement : ~1 Go de t\xE9l\xE9chargement)...");
2857
- const answer = await callLocalLlm(prompt, lang);
2858
- console.log(process.stdout.isTTY ? renderAnswerTerminal(answer) : answer);
2859
- exit(0);
2860
- }
2861
3819
  if (values.call) {
2862
3820
  const apiKey = process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || "";
2863
3821
  if (!apiKey) {