dsh-codebase-chat 0.19.0 → 0.22.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
@@ -4,7 +4,7 @@
4
4
  import { parseArgs } from "util";
5
5
 
6
6
  // src/context.ts
7
- import { join as join3 } from "path";
7
+ import { join as join5 } from "path";
8
8
 
9
9
  // src/tokenizer.ts
10
10
  import { encode, decode } from "gpt-tokenizer";
@@ -33,14 +33,281 @@ function truncateToTokens(text, maxTokens) {
33
33
  }
34
34
 
35
35
  // src/indexer.ts
36
- import { mkdir, readFile as readFile2, stat as stat2, writeFile } from "fs/promises";
37
- import { dirname, join as join2, relative, sep } from "path";
36
+ import { mkdir, readFile as readFile3, stat as stat2, writeFile } from "fs/promises";
37
+ import { dirname as dirname2, join as join4, relative as relative2, sep as sep2 } from "path";
38
38
  import { existsSync } from "fs";
39
39
 
40
40
  // src/extractor.ts
41
41
  import { parse } from "@babel/parser";
42
42
  import traverse from "@babel/traverse";
43
43
  import * as t from "@babel/types";
44
+
45
+ // src/merge-gaps.ts
46
+ function mergeGaps(content, relPath, namedChunks, visitedRanges) {
47
+ const lines = content.split("\n");
48
+ if (lines.length === 0) return namedChunks;
49
+ const covered = /* @__PURE__ */ new Set();
50
+ for (const [start2, end] of visitedRanges) {
51
+ for (let i = start2; i <= end; i++) covered.add(i);
52
+ }
53
+ const all = [];
54
+ let start = 1;
55
+ for (let i = 1; i <= lines.length; i++) {
56
+ if (!covered.has(i)) continue;
57
+ if (i > start) {
58
+ const gapText = lines.slice(start - 1, i - 1).join("\n");
59
+ if (gapText.trim()) all.push({ relPath, startLine: start, endLine: i - 1, content: gapText, tokens: countTokens(gapText), kind: "file" });
60
+ }
61
+ start = i + 1;
62
+ }
63
+ if (start <= lines.length) {
64
+ const gapText = lines.slice(start - 1).join("\n");
65
+ if (gapText.trim()) all.push({ relPath, startLine: start, endLine: lines.length, content: gapText, tokens: countTokens(gapText), kind: "file" });
66
+ }
67
+ return [...all, ...namedChunks].sort((a, b) => a.startLine - b.startLine);
68
+ }
69
+
70
+ // src/treesitter.ts
71
+ import { createRequire } from "module";
72
+ import { dirname, join } from "path";
73
+ var require2 = createRequire(import.meta.url);
74
+ var GRAMMAR_BY_EXT = {
75
+ ".py": "python",
76
+ ".go": "go",
77
+ ".rs": "rust",
78
+ ".java": "java",
79
+ ".cs": "c_sharp",
80
+ ".php": "php"
81
+ };
82
+ var DECL_TYPES = {
83
+ python: {
84
+ function_definition: "function",
85
+ class_definition: "class"
86
+ },
87
+ go: {
88
+ function_declaration: "function",
89
+ method_declaration: "method",
90
+ type_declaration: "type",
91
+ import_declaration: "import",
92
+ const_declaration: "unknown",
93
+ var_declaration: "unknown"
94
+ },
95
+ rust: {
96
+ function_item: "function",
97
+ struct_item: "type",
98
+ enum_item: "type",
99
+ union_item: "type",
100
+ trait_item: "type",
101
+ type_item: "type",
102
+ impl_item: "type",
103
+ mod_item: "unknown",
104
+ use_declaration: "import",
105
+ macro_definition: "unknown"
106
+ },
107
+ java: {
108
+ class_declaration: "class",
109
+ interface_declaration: "type",
110
+ enum_declaration: "type",
111
+ record_declaration: "type",
112
+ annotation_type_declaration: "type",
113
+ method_declaration: "method",
114
+ constructor_declaration: "method",
115
+ field_declaration: "unknown",
116
+ import_declaration: "import",
117
+ package_declaration: "import"
118
+ },
119
+ c_sharp: {
120
+ class_declaration: "class",
121
+ interface_declaration: "type",
122
+ struct_declaration: "type",
123
+ enum_declaration: "type",
124
+ record_declaration: "type",
125
+ delegate_declaration: "type",
126
+ method_declaration: "method",
127
+ constructor_declaration: "method",
128
+ property_declaration: "method",
129
+ field_declaration: "unknown",
130
+ using_directive: "import"
131
+ },
132
+ php: {
133
+ function_definition: "function",
134
+ class_declaration: "class",
135
+ interface_declaration: "type",
136
+ trait_declaration: "type",
137
+ enum_declaration: "type",
138
+ method_declaration: "method",
139
+ namespace_use_declaration: "import",
140
+ namespace_definition: "unknown"
141
+ }
142
+ };
143
+ var RECURSE_INTO = /* @__PURE__ */ new Set([
144
+ "program",
145
+ "module",
146
+ "translation_unit",
147
+ "compilation_unit",
148
+ "source_file",
149
+ "namespace_declaration",
150
+ "file_scoped_namespace_declaration",
151
+ "namespace_definition",
152
+ "declaration_list",
153
+ "decorated_definition",
154
+ "export_statement"
155
+ ]);
156
+ var CONTAINER_TYPES = /* @__PURE__ */ new Set([
157
+ "class_definition",
158
+ "class_declaration",
159
+ "interface_declaration",
160
+ "enum_declaration",
161
+ "record_declaration",
162
+ "struct_declaration",
163
+ "impl_item",
164
+ "trait_item",
165
+ "trait_declaration"
166
+ ]);
167
+ var MEMBER_TYPES = /* @__PURE__ */ new Set([
168
+ "function_definition",
169
+ "method_declaration",
170
+ "method_definition",
171
+ "function_item",
172
+ "constructor_declaration",
173
+ "property_declaration"
174
+ ]);
175
+ var NAME_TYPES = /* @__PURE__ */ new Set([
176
+ "identifier",
177
+ "type_identifier",
178
+ "field_identifier",
179
+ "simple_type",
180
+ "name"
181
+ ]);
182
+ var parser = null;
183
+ var initPromise = null;
184
+ var languages = /* @__PURE__ */ new Map();
185
+ var langPromises = /* @__PURE__ */ new Map();
186
+ async function initTreeSitter() {
187
+ if (parser) return true;
188
+ initPromise ??= (async () => {
189
+ try {
190
+ const mod = await import("web-tree-sitter");
191
+ const ParserClass = mod.default ?? mod;
192
+ const wasm = require2.resolve("web-tree-sitter/tree-sitter.wasm");
193
+ await ParserClass.init({ locateFile: () => wasm });
194
+ parser = new ParserClass();
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ })();
200
+ return initPromise;
201
+ }
202
+ function grammarPath(name) {
203
+ const pkg = require2.resolve("tree-sitter-wasms/package.json");
204
+ return join(dirname(pkg), "out", `tree-sitter-${name}.wasm`);
205
+ }
206
+ async function ensureLanguage(name) {
207
+ if (languages.has(name)) return languages.get(name);
208
+ let p = langPromises.get(name);
209
+ if (!p) {
210
+ p = (async () => {
211
+ try {
212
+ const mod = await import("web-tree-sitter");
213
+ const ParserClass = mod.default ?? mod;
214
+ const lang = await ParserClass.Language.load(grammarPath(name));
215
+ languages.set(name, lang);
216
+ return lang;
217
+ } catch {
218
+ languages.set(name, null);
219
+ return null;
220
+ }
221
+ })();
222
+ langPromises.set(name, p);
223
+ }
224
+ return p;
225
+ }
226
+ async function ensureTreeSitterForExt(ext) {
227
+ const name = GRAMMAR_BY_EXT[ext.toLowerCase()];
228
+ if (!name) return false;
229
+ if (!await initTreeSitter()) return false;
230
+ return await ensureLanguage(name) != null;
231
+ }
232
+ function disposeTreeSitter() {
233
+ try {
234
+ parser?.delete();
235
+ } catch {
236
+ }
237
+ parser = null;
238
+ initPromise = null;
239
+ languages.clear();
240
+ langPromises.clear();
241
+ }
242
+ function treeSitterReady(ext) {
243
+ const name = GRAMMAR_BY_EXT[ext.toLowerCase()];
244
+ return !!name && !!parser && languages.get(name) != null;
245
+ }
246
+ function nodeName(node) {
247
+ const named = node.childForFieldName("name");
248
+ if (named) return named.text;
249
+ for (const child of node.namedChildren) {
250
+ if (NAME_TYPES.has(child.type)) return child.text;
251
+ const inner = child.childForFieldName("name");
252
+ if (inner) return inner.text;
253
+ }
254
+ return void 0;
255
+ }
256
+ function toChunk(node, kind, relPath, content, name) {
257
+ const startLine = node.startPosition.row + 1;
258
+ const endLine = node.endPosition.row + 1;
259
+ const text = content.split("\n").slice(startLine - 1, endLine).join("\n");
260
+ return { relPath, startLine, endLine, content: text, tokens: countTokens(text), kind, name };
261
+ }
262
+ function extractWithTreeSitter(ext, relPath, content) {
263
+ if (!parser || !treeSitterReady(ext)) return void 0;
264
+ const name = GRAMMAR_BY_EXT[ext.toLowerCase()];
265
+ const lang = languages.get(name);
266
+ const declTypes = DECL_TYPES[name] ?? {};
267
+ let tree;
268
+ try {
269
+ parser.setLanguage(lang);
270
+ tree = parser.parse(content);
271
+ } catch {
272
+ return void 0;
273
+ }
274
+ if (!tree) return void 0;
275
+ try {
276
+ const chunks = [];
277
+ const visitedRanges = [];
278
+ const handle = (child) => {
279
+ const kind = declTypes[child.type];
280
+ if (kind) {
281
+ const target = child.parent?.type === "decorated_definition" ? child.parent : child;
282
+ chunks.push(toChunk(target, kind, relPath, content, nodeName(child)));
283
+ visitedRanges.push([target.startPosition.row + 1, target.endPosition.row + 1]);
284
+ if (CONTAINER_TYPES.has(child.type)) {
285
+ const body = child.childForFieldName("body") ?? child;
286
+ for (const member of body.namedChildren) {
287
+ if (MEMBER_TYPES.has(member.type)) {
288
+ chunks.push(toChunk(member, "method", relPath, content, nodeName(member)));
289
+ } else if (declTypes[member.type]) {
290
+ handle(member);
291
+ }
292
+ }
293
+ }
294
+ return;
295
+ }
296
+ if (RECURSE_INTO.has(child.type) || child.namedChildren.some((c) => declTypes[c.type] || RECURSE_INTO.has(c.type))) {
297
+ visit(child);
298
+ }
299
+ };
300
+ const visit = (node) => {
301
+ for (const child of node.namedChildren) handle(child);
302
+ };
303
+ visit(tree.rootNode);
304
+ return mergeGaps(content, relPath, chunks, visitedRanges);
305
+ } finally {
306
+ tree.delete();
307
+ }
308
+ }
309
+
310
+ // src/extractor.ts
44
311
  var JS_LIKE = /* @__PURE__ */ new Set([
45
312
  ".js",
46
313
  ".jsx",
@@ -162,51 +429,6 @@ function extractTopLevelWithBabel(relPath, content) {
162
429
  });
163
430
  return mergeGaps(content, relPath, chunks, visitedRanges);
164
431
  }
165
- function mergeGaps(content, relPath, namedChunks, visitedRanges) {
166
- const lines = content.split("\n");
167
- if (lines.length === 0) return namedChunks;
168
- const covered = /* @__PURE__ */ new Set();
169
- for (const [start2, end] of visitedRanges) {
170
- for (let i = start2; i <= end; i++) covered.add(i);
171
- }
172
- const all = [];
173
- let start = 1;
174
- for (let i = 1; i <= lines.length; i++) {
175
- if (!covered.has(i)) {
176
- if (i === start) {
177
- start++;
178
- continue;
179
- }
180
- continue;
181
- }
182
- if (i > start) {
183
- const gap = {
184
- relPath,
185
- startLine: start,
186
- endLine: i - 1,
187
- content: lines.slice(start - 1, i - 1).join("\n"),
188
- tokens: 0,
189
- kind: "file"
190
- };
191
- gap.tokens = countTokens(gap.content);
192
- if (gap.content.trim()) all.push(gap);
193
- }
194
- start = i + 1;
195
- }
196
- if (start <= lines.length) {
197
- const gap = {
198
- relPath,
199
- startLine: start,
200
- endLine: lines.length,
201
- content: lines.slice(start - 1).join("\n"),
202
- tokens: 0,
203
- kind: "file"
204
- };
205
- gap.tokens = countTokens(gap.content);
206
- if (gap.content.trim()) all.push(gap);
207
- }
208
- return [...all, ...namedChunks].sort((a, b) => a.startLine - b.startLine);
209
- }
210
432
  function extractWithRegex(relPath, content) {
211
433
  const lines = content.split("\n");
212
434
  const chunks = [];
@@ -255,6 +477,13 @@ function extractChunks(relPath, content) {
255
477
  return extractWithRegex(relPath, content);
256
478
  }
257
479
  }
480
+ if (treeSitterReady(ext)) {
481
+ try {
482
+ const chunks = extractWithTreeSitter(ext, relPath, content);
483
+ if (chunks && chunks.length) return chunks;
484
+ } catch {
485
+ }
486
+ }
258
487
  return extractWithRegex(relPath, content);
259
488
  }
260
489
 
@@ -309,10 +538,64 @@ function cosineSimilarity(a, b) {
309
538
  }
310
539
 
311
540
  // src/project.ts
312
- import { readdir, readFile, stat } from "fs/promises";
313
- import { extname, join, resolve, isAbsolute } from "path";
541
+ import { readdir, readFile as readFile2, stat } from "fs/promises";
542
+ import { extname, join as join3, relative, resolve, sep, isAbsolute } from "path";
314
543
  import { createHash } from "crypto";
315
544
  import { homedir } from "os";
545
+
546
+ // src/config.ts
547
+ import { readFile } from "fs/promises";
548
+ import { join as join2 } from "path";
549
+ var CONFIG_FILE = ".codebase-chat.json";
550
+ var configCache = /* @__PURE__ */ new Map();
551
+ function strArray(v) {
552
+ if (!Array.isArray(v)) return void 0;
553
+ const out = v.filter((x) => typeof x === "string" && x.trim().length > 0);
554
+ return out.length ? out.map((s) => s.trim()) : void 0;
555
+ }
556
+ function sanitize(raw) {
557
+ if (raw == null || typeof raw !== "object") return {};
558
+ const o = raw;
559
+ const cfg = {};
560
+ if (o.lang === "fr" || o.lang === "en") cfg.lang = o.lang;
561
+ if (typeof o.maxTokens === "number" && Number.isFinite(o.maxTokens) && o.maxTokens > 0) {
562
+ cfg.maxTokens = Math.floor(o.maxTokens);
563
+ }
564
+ const ignoreDirs = strArray(o.ignoreDirs);
565
+ if (ignoreDirs) cfg.ignoreDirs = ignoreDirs;
566
+ const ignoreFiles = strArray(o.ignoreFiles);
567
+ if (ignoreFiles) cfg.ignoreFiles = ignoreFiles;
568
+ const ignoreGlobs = strArray(o.ignoreGlobs);
569
+ if (ignoreGlobs) cfg.ignoreGlobs = ignoreGlobs;
570
+ const protectedPaths = strArray(o.protectedPaths);
571
+ if (protectedPaths) cfg.protectedPaths = protectedPaths;
572
+ return cfg;
573
+ }
574
+ async function loadProjectConfig(absProject) {
575
+ const cached = configCache.get(absProject);
576
+ if (cached) return cached;
577
+ let cfg = {};
578
+ try {
579
+ cfg = sanitize(JSON.parse(await readFile(join2(absProject, CONFIG_FILE), "utf8")));
580
+ } catch {
581
+ }
582
+ configCache.set(absProject, cfg);
583
+ return cfg;
584
+ }
585
+ function escapeSegment(s) {
586
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
587
+ }
588
+ function globToRegExp(glob) {
589
+ const src = glob.replace(/\\/g, "/").split("**").map(escapeSegment).join(".*");
590
+ return new RegExp(`^${src}$`);
591
+ }
592
+ function matchesAnyGlob(relPath, globs) {
593
+ if (!globs || globs.length === 0) return false;
594
+ const rel = relPath.replace(/\\/g, "/");
595
+ return globs.some((g) => globToRegExp(g).test(rel));
596
+ }
597
+
598
+ // src/project.ts
316
599
  var SOURCE_EXTS = /* @__PURE__ */ new Set([
317
600
  ".ts",
318
601
  ".tsx",
@@ -368,6 +651,14 @@ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
368
651
  ".dsh-vision-router"
369
652
  ]);
370
653
  var DEFAULT_SKIP_FILES = /* @__PURE__ */ new Set([]);
654
+ async function getWalkOptions(absProject) {
655
+ const cfg = await loadProjectConfig(absProject);
656
+ return {
657
+ skipDirs: /* @__PURE__ */ new Set([...DEFAULT_SKIP_DIRS, ...cfg.ignoreDirs ?? []]),
658
+ skipFiles: /* @__PURE__ */ new Set([...DEFAULT_SKIP_FILES, ...cfg.ignoreFiles ?? []]),
659
+ ignoreGlobs: cfg.ignoreGlobs ?? []
660
+ };
661
+ }
371
662
  function projectHash(absProject) {
372
663
  return createHash("sha256").update(absProject.toLowerCase()).digest("hex").slice(0, 16);
373
664
  }
@@ -388,16 +679,16 @@ async function findProjectRoot(absProject) {
388
679
  }
389
680
  }
390
681
  function getCacheDir() {
391
- const base = process.env.CODEBASE_CACHE_DIR || process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), ".cache");
392
- return join(base, "dsh-codebase-chat-cache");
682
+ const base = process.env.CODEBASE_CACHE_DIR || process.env.LOCALAPPDATA || process.env.APPDATA || join3(homedir(), ".cache");
683
+ return join3(base, "dsh-codebase-chat-cache");
393
684
  }
394
685
  function cacheFilePath(absProject) {
395
- return join(getCacheDir(), `${projectHash(absProject)}.json`);
686
+ return join3(getCacheDir(), `${projectHash(absProject)}.json`);
396
687
  }
397
688
  function fileHash(stats, firstBytes = "") {
398
689
  return createHash("sha256").update(`${stats.mtimeMs}:${stats.size}:${firstBytes.slice(0, 512)}`).digest("hex").slice(0, 24);
399
690
  }
400
- async function* walkFiles(startDir, skipDirs = DEFAULT_SKIP_DIRS, skipFiles = DEFAULT_SKIP_FILES) {
691
+ async function* walkFiles(startDir, skipDirs = DEFAULT_SKIP_DIRS, skipFiles = DEFAULT_SKIP_FILES, ignoreGlobs = []) {
401
692
  const queue = [startDir];
402
693
  while (queue.length) {
403
694
  const dir = queue.shift();
@@ -408,13 +699,15 @@ async function* walkFiles(startDir, skipDirs = DEFAULT_SKIP_DIRS, skipFiles = DE
408
699
  continue;
409
700
  }
410
701
  for (const entry of entries) {
411
- const fullPath = join(dir, entry.name);
702
+ const fullPath = join3(dir, entry.name);
703
+ const rel = relative(startDir, fullPath).split(sep).join("/");
412
704
  if (entry.isDirectory()) {
413
- if (!skipDirs.has(entry.name)) queue.push(fullPath);
705
+ if (!skipDirs.has(entry.name) && !matchesAnyGlob(rel, ignoreGlobs)) queue.push(fullPath);
414
706
  continue;
415
707
  }
416
708
  if (!entry.isFile()) continue;
417
709
  if (skipFiles.has(entry.name)) continue;
710
+ if (matchesAnyGlob(rel, ignoreGlobs)) continue;
418
711
  const ext = extname(entry.name).toLowerCase();
419
712
  if (!SOURCE_EXTS.has(ext)) continue;
420
713
  yield fullPath;
@@ -423,7 +716,7 @@ async function* walkFiles(startDir, skipDirs = DEFAULT_SKIP_DIRS, skipFiles = DE
423
716
  }
424
717
  async function safeReadText(filePath) {
425
718
  try {
426
- const text = await readFile(filePath, "utf8");
719
+ const text = await readFile2(filePath, "utf8");
427
720
  return text;
428
721
  } catch {
429
722
  return void 0;
@@ -443,7 +736,7 @@ async function buildTree(startDir, maxLines = 500, skipDirs = DEFAULT_SKIP_DIRS)
443
736
  for (const entry of entries) {
444
737
  if (lines.length >= maxLines) return;
445
738
  if (skipDirs.has(entry.name)) continue;
446
- const fullPath = join(dir, entry.name);
739
+ const fullPath = join3(dir, entry.name);
447
740
  if (entry.isDirectory()) {
448
741
  lines.push(`${prefix}${entry.name}/`);
449
742
  await walk(fullPath, `${prefix} `);
@@ -582,7 +875,7 @@ async function loadIndex(projectPath) {
582
875
  const p = cacheFilePath(absProject);
583
876
  if (!existsSync(p)) return null;
584
877
  try {
585
- const raw = await readFile2(p, "utf8");
878
+ const raw = await readFile3(p, "utf8");
586
879
  const data = JSON.parse(raw);
587
880
  if (data.version !== INDEX_VERSION) return null;
588
881
  return data;
@@ -592,7 +885,7 @@ async function loadIndex(projectPath) {
592
885
  }
593
886
  async function saveIndex(index) {
594
887
  const p = cacheFilePath(index.projectPath);
595
- await mkdir(dirname(p), { recursive: true });
888
+ await mkdir(dirname2(p), { recursive: true });
596
889
  await writeFile(p, JSON.stringify(index), "utf8");
597
890
  }
598
891
  async function embedIndex(index, progress) {
@@ -618,17 +911,18 @@ async function embedIndex(index, progress) {
618
911
  }
619
912
  async function buildIndex(projectPath, progress) {
620
913
  const absProject = await findProjectRoot(resolveProjectPath(projectPath));
621
- const projectName = absProject.split(sep).pop() ?? "project";
914
+ const projectName = absProject.split(sep2).pop() ?? "project";
622
915
  progress?.(`Indexing ${projectName}...`);
623
- const tree = await buildTree(absProject);
916
+ const walk = await getWalkOptions(absProject);
917
+ const tree = await buildTree(absProject, void 0, walk.skipDirs);
624
918
  const startDir = absProject;
625
919
  const previous = await loadIndex(absProject);
626
920
  const previousFiles = previous?.projectPath === absProject ? previous.files : {};
627
921
  const files = {};
628
922
  let totalTokens = 0;
629
923
  let reused = 0;
630
- for await (const fullPath of walkFiles(startDir)) {
631
- const relPath = relative(startDir, fullPath).split(sep).join("/");
924
+ for await (const fullPath of walkFiles(startDir, walk.skipDirs, walk.skipFiles, walk.ignoreGlobs)) {
925
+ const relPath = relative2(startDir, fullPath).split(sep2).join("/");
632
926
  progress?.(`Reading ${relPath}`);
633
927
  const fstats = await stat2(fullPath);
634
928
  const cached = previousFiles[relPath];
@@ -641,6 +935,8 @@ async function buildIndex(projectPath, progress) {
641
935
  const text = await safeReadText(fullPath);
642
936
  if (!text) continue;
643
937
  const hash = fileHash(fstats, text);
938
+ const ext = relPath.slice(relPath.lastIndexOf(".")).toLowerCase();
939
+ await ensureTreeSitterForExt(ext);
644
940
  const chunks = extractChunks(relPath, text).map((chunk) => ({
645
941
  ...chunk,
646
942
  // recompute tokens to be safe
@@ -678,7 +974,7 @@ async function getIndex(projectPath, progress, force = false) {
678
974
  if (existing && existing.projectPath === absProject) {
679
975
  let stale = false;
680
976
  for (const file of Object.values(existing.files)) {
681
- const fullPath = join2(absProject, file.relPath);
977
+ const fullPath = join4(absProject, file.relPath);
682
978
  try {
683
979
  const fstats = await stat2(fullPath);
684
980
  if (fstats.mtimeMs !== file.mtimeMs || fstats.size !== file.size) {
@@ -897,7 +1193,7 @@ async function extractProductConstraints(absProject) {
897
1193
  const candidates = ["README.md", "README.MD", "readme.md", "MEMORY.md", "CONTRIBUTING.md"];
898
1194
  const constraints = [];
899
1195
  for (const name of candidates) {
900
- const text = await safeReadText(join3(absProject, name));
1196
+ const text = await safeReadText(join5(absProject, name));
901
1197
  if (!text) continue;
902
1198
  const regex = /(?:constraint|contrainte|must|doit|interdit|forbidden|rule|règle|limitation)[\s\S]{0,200}/gi;
903
1199
  let m;
@@ -917,9 +1213,12 @@ function formatChunk(chunk) {
917
1213
  ${chunk.content}`;
918
1214
  }
919
1215
  async function buildContext(options) {
920
- const { project, query, filePath, searchQuery, maxTokens = DEFAULT_MAX_TOKENS, lang = "fr", instruction, embed = false } = options;
921
- const labels = getLabels(lang);
1216
+ const { project, query, filePath, searchQuery, instruction, embed = false } = options;
922
1217
  const absProject = await findProjectRoot(resolveProjectPath(project));
1218
+ const cfg = await loadProjectConfig(absProject);
1219
+ const maxTokens = options.maxTokens ?? cfg.maxTokens ?? DEFAULT_MAX_TOKENS;
1220
+ const lang = options.lang ?? cfg.lang ?? "fr";
1221
+ const labels = getLabels(lang);
923
1222
  const index = await getIndex(absProject);
924
1223
  if (embed) {
925
1224
  try {
@@ -991,8 +1290,8 @@ ${finalInstruction}`;
991
1290
  }
992
1291
 
993
1292
  // src/analysis.ts
994
- import { basename, extname as extname2, join as join4, relative as relative2, sep as sep2, posix as posixPath } from "path";
995
- import { readFile as readFile3 } from "fs/promises";
1293
+ import { basename, extname as extname2, join as join6, relative as relative3, sep as sep3, posix as posixPath } from "path";
1294
+ import { readFile as readFile4 } from "fs/promises";
996
1295
  var CODE_EXTS = /* @__PURE__ */ new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
997
1296
  var ENTRY_BASENAMES = /* @__PURE__ */ new Set(["index", "main", "app", "cli", "server", "bin", "mod"]);
998
1297
  var SKIP_EXTS = /* @__PURE__ */ new Set([".d.ts", ".test.ts", ".test.js", ".spec.ts", ".spec.js", ".config.js", ".config.ts", ".config.mjs"]);
@@ -1068,6 +1367,8 @@ function findCycles(edges) {
1068
1367
  function looksLikeEntry(rel, pkg) {
1069
1368
  const base = basename(rel).toLowerCase().replace(extname2(rel), "");
1070
1369
  if (ENTRY_BASENAMES.has(base)) return true;
1370
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || rel.includes("__tests__/") || /^e2e[.-]/.test(basename(rel))) return true;
1371
+ if (/\.(config|rc)\.[cm]?[jt]s$/.test(rel)) return true;
1071
1372
  if (/^(pages|app|routes|api|bin|scripts)\//.test(rel) || rel.includes("/pages/") || rel.includes("/routes/")) return true;
1072
1373
  const fields = [pkg?.main, pkg?.module, pkg?.bin, pkg?.exports?.["."]];
1073
1374
  for (const f of fields.flatMap((v) => typeof v === "string" ? [v] : v ? Object.values(v) : [])) {
@@ -1108,8 +1409,9 @@ async function analyzeProject(projectPath) {
1108
1409
  const abs = await findProjectRoot(resolveProjectPath(projectPath));
1109
1410
  const fileTexts = /* @__PURE__ */ new Map();
1110
1411
  const codeFiles = [];
1111
- for await (const full of walkFiles(abs)) {
1112
- const rel = relative2(abs, full).split(sep2).join("/");
1412
+ const walk = await getWalkOptions(abs);
1413
+ for await (const full of walkFiles(abs, walk.skipDirs, walk.skipFiles, walk.ignoreGlobs)) {
1414
+ const rel = relative3(abs, full).split(sep3).join("/");
1113
1415
  const ext = extname2(rel).toLowerCase();
1114
1416
  if (!CODE_EXTS.has(ext) || SKIP_EXTS.has(ext) || rel.includes(".min.")) continue;
1115
1417
  const text = await safeReadText(full);
@@ -1119,7 +1421,7 @@ async function analyzeProject(projectPath) {
1119
1421
  }
1120
1422
  let pkg = {};
1121
1423
  try {
1122
- pkg = JSON.parse(await readFile3(join4(abs, "package.json"), "utf8"));
1424
+ pkg = JSON.parse(await readFile4(join6(abs, "package.json"), "utf8"));
1123
1425
  } catch {
1124
1426
  }
1125
1427
  const known = new Set(codeFiles);
@@ -1159,7 +1461,7 @@ async function analyzeProject(projectPath) {
1159
1461
  hotspots.sort((a, b) => b.score - a.score);
1160
1462
  const codeLines = [...fileTexts.values()].reduce((s, t2) => s + t2.split("\n").length, 0);
1161
1463
  const dupLines = duplicates.reduce((s, g) => s + g.lines, 0);
1162
- const penalties = cycles.length * 6 + unusedFiles.length * 2 + Math.min(unusedExports.length, 20) * 1 + Math.round(dupLines / Math.max(codeLines, 1) * 100) + hotspots.length * 2;
1464
+ const penalties = cycles.length * 6 + unusedFiles.length * 2 + Math.min(unusedExports.length, 20) * 1 + Math.round(dupLines / Math.max(codeLines, 1) * 100) + Math.min(hotspots.length, 15) * 2;
1163
1465
  const score = Math.max(0, Math.min(100, 100 - penalties));
1164
1466
  const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 65 ? "C" : score >= 50 ? "D" : "E";
1165
1467
  return {
@@ -1170,7 +1472,7 @@ async function analyzeProject(projectPath) {
1170
1472
  unusedFiles,
1171
1473
  unusedExports,
1172
1474
  duplicates,
1173
- hotspots: hotspots.slice(0, 15),
1475
+ hotspots,
1174
1476
  score,
1175
1477
  grade
1176
1478
  };
@@ -1224,6 +1526,17 @@ function formatHealthReport(r, lang = "fr") {
1224
1526
  }
1225
1527
 
1226
1528
  // src/cli.ts
1529
+ var ExitSignal = class {
1530
+ constructor(code) {
1531
+ this.code = code;
1532
+ }
1533
+ code;
1534
+ };
1535
+ function exit(code) {
1536
+ disposeTreeSitter();
1537
+ setTimeout(() => process.exit(code), 2e3).unref();
1538
+ throw new ExitSignal(code);
1539
+ }
1227
1540
  function printHelp() {
1228
1541
  console.log(`
1229
1542
  dsh-codebase-chat CLI
@@ -1245,9 +1558,13 @@ Options:
1245
1558
  -t, --stats Print indexing stats
1246
1559
  -H, --health Deterministic static analysis (cycles, dead code, dupes, complexity)
1247
1560
  -e, --embed Enable local semantic embeddings (slower, more relevant)
1248
- --lang <en|fr> Language for headings (default: fr)
1561
+ --lang <en|fr> Language for headings (default: .codebase-chat.json lang, else fr)
1249
1562
  -h, --help Show this help
1250
1563
 
1564
+ Config:
1565
+ .codebase-chat.json Per-project settings: lang, maxTokens, ignoreDirs,
1566
+ ignoreFiles, ignoreGlobs, protectedPaths
1567
+
1251
1568
  Environment:
1252
1569
  CODEBASE_CACHE_DIR Directory for the index cache
1253
1570
  `);
@@ -1263,20 +1580,21 @@ async function main() {
1263
1580
  stats: { type: "boolean", short: "t", default: false },
1264
1581
  health: { type: "boolean", short: "H", default: false },
1265
1582
  embed: { type: "boolean", short: "e", default: false },
1266
- lang: { type: "string", default: "fr" },
1583
+ lang: { type: "string" },
1267
1584
  help: { type: "boolean", short: "h", default: false }
1268
1585
  },
1269
1586
  allowPositionals: false
1270
1587
  });
1271
1588
  if (values.help) {
1272
1589
  printHelp();
1273
- process.exit(0);
1590
+ exit(0);
1274
1591
  }
1275
- const lang = values.lang === "en" ? "en" : "fr";
1276
1592
  const project = resolveProjectPath(values.project);
1593
+ const cfg = await loadProjectConfig(project);
1594
+ const lang = values.lang === "en" || values.lang === "fr" ? values.lang : cfg.lang ?? "fr";
1277
1595
  if (values.index) {
1278
1596
  await getIndex(project, (m) => console.log(m), true);
1279
- process.exit(0);
1597
+ exit(0);
1280
1598
  }
1281
1599
  if (values.stats) {
1282
1600
  const index = await getIndex(project, (m) => console.log(m));
@@ -1288,12 +1606,12 @@ async function main() {
1288
1606
  console.log(`Tokens: ${totalTokens}`);
1289
1607
  console.log(`Terms: ${termCount}`);
1290
1608
  console.log(`Cache: ${index.projectHash}`);
1291
- process.exit(0);
1609
+ exit(0);
1292
1610
  }
1293
1611
  if (values.health) {
1294
1612
  const report = await analyzeProject(project);
1295
1613
  console.log(formatHealthReport(report, lang));
1296
- process.exit(0);
1614
+ exit(0);
1297
1615
  }
1298
1616
  if (values.ask || values.search || values.file) {
1299
1617
  const result = await buildContext({
@@ -1310,13 +1628,19 @@ async function main() {
1310
1628
  console.log(`Project: ${result.absProject}`);
1311
1629
  console.log(`Chunks: ${result.chunks.length}`);
1312
1630
  console.log(`Tokens: ${result.tokenCount}`);
1313
- process.exit(0);
1631
+ exit(0);
1314
1632
  }
1315
1633
  printHelp();
1316
- process.exit(1);
1634
+ exit(1);
1317
1635
  }
1318
1636
  main().catch((err) => {
1637
+ disposeTreeSitter();
1638
+ if (err instanceof ExitSignal) {
1639
+ process.exitCode = err.code;
1640
+ return;
1641
+ }
1319
1642
  console.error(err?.message ?? err);
1320
- process.exit(1);
1643
+ process.exitCode = 1;
1644
+ setTimeout(() => process.exit(1), 2e3).unref();
1321
1645
  });
1322
1646
  //# sourceMappingURL=cli.js.map