local-codebase-indexer 0.3.0__tar.gz → 0.3.2__tar.gz

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.
Files changed (68) hide show
  1. {local_codebase_indexer-0.3.0/local_codebase_indexer.egg-info → local_codebase_indexer-0.3.2}/PKG-INFO +1 -1
  2. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/TODO.md +1 -0
  3. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/__init__.py +1 -1
  4. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/cli.py +17 -0
  5. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/core.py +1 -1
  6. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/query.py +59 -5
  7. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2/local_codebase_indexer.egg-info}/PKG-INFO +1 -1
  8. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/server.json +2 -2
  9. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/tests/test_dataaccess.py +30 -0
  10. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/tests/test_indexer.py +27 -0
  11. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/AGENTS.md +0 -0
  12. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/CLAUDE.md +0 -0
  13. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/MANIFEST.in +0 -0
  14. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/README.md +0 -0
  15. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/README.zh-CN.md +0 -0
  16. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/README.zh-TW.md +0 -0
  17. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/README.md +0 -0
  18. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/baseline.json +0 -0
  19. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Auth/LoginService.cs +0 -0
  20. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Controllers/LotController.cs +0 -0
  21. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Controllers/ScrapController.cs +0 -0
  22. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Data/MesDbContext.cs +0 -0
  23. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Legacy/frmChangeLine.frm +0 -0
  24. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Models/DrillBreakEvent.cs +0 -0
  25. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Models/Lot.cs +0 -0
  26. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Models/ScrapRecord.cs +0 -0
  27. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Printing/LabelPrinter.cs +0 -0
  28. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Repositories/LotRepository.cs +0 -0
  29. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Repositories/ScrapRepository.cs +0 -0
  30. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Services/IScrapService.cs +0 -0
  31. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Services/InspectionService.cs +0 -0
  32. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Services/LotService.cs +0 -0
  33. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Services/ScrapService.cs +0 -0
  34. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Sql/MES_Lot.sql +0 -0
  35. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Sql/usp_Inspection_Report.sql +0 -0
  36. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Sql/usp_Lot_UpdateStatus.sql +0 -0
  37. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Sql/usp_Scrap_Insert.sql +0 -0
  38. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Utils/DateRangeHelper.cs +0 -0
  39. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Utils/PartNumberDecoder.cs +0 -0
  40. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Web/scrapForm.ts +0 -0
  41. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/Web.config +0 -0
  42. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/corpus/tools/export_report.py +0 -0
  43. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/queries_stdlib.json +0 -0
  44. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/queries_synthetic.json +0 -0
  45. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/benchmarks/run_benchmark.py +0 -0
  46. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/index.md +0 -0
  47. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/llms.txt +0 -0
  48. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/__main__.py +0 -0
  49. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/callgraph.py +0 -0
  50. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/cscalls.py +0 -0
  51. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/csdata.py +0 -0
  52. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/dataflow.py +0 -0
  53. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/db.py +0 -0
  54. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/embedder.py +0 -0
  55. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/langparse.py +0 -0
  56. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/mcp_server.py +0 -0
  57. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/scanner.py +0 -0
  58. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/sqlaccess.py +0 -0
  59. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer/synonyms.py +0 -0
  60. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer.egg-info/SOURCES.txt +0 -0
  61. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer.egg-info/dependency_links.txt +0 -0
  62. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer.egg-info/entry_points.txt +0 -0
  63. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer.egg-info/requires.txt +0 -0
  64. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/local_codebase_indexer.egg-info/top_level.txt +0 -0
  65. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/pyproject.toml +0 -0
  66. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/requirements.txt +0 -0
  67. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/setup.cfg +0 -0
  68. {local_codebase_indexer-0.3.0 → local_codebase_indexer-0.3.2}/tests/test_onnx_plumbing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: local-codebase-indexer
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Local codebase indexer and context provider for AI coding assistants: MCP server and CLI with SQL data-access and C# call relations. No cloud, no server.
5
5
  Author: sfy257000
6
6
  Project-URL: Homepage, https://github.com/sfy257000/local-codebase-indexer
@@ -4,6 +4,7 @@ Priorities come from the benchmark (`benchmarks/run_benchmark.py`, baseline in `
4
4
 
5
5
  ## Next
6
6
 
7
+ 0. **Publish to the MCP registry** (not done yet). PyPI 0.3.0 is out and carries the `mcp-name` marker; `server.json` is ready. Run `mcp-publisher login github` then `mcp-publisher publish` from the repo folder, then verify with `curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=local-codebase-indexer"`. Repeat after each release (bump both versions in `server.json`).
7
8
  1. **Check a real embedding model on your own machine** (this environment cannot reach Hugging Face, so the ONNX path is only tested with a tiny generated model). Export a model such as `all-MiniLM-L6-v2` (mean pooling) or `bge-small-en-v1.5` (`INDEXER_ONNX_POOLING=cls`, `INDEXER_ONNX_QUERY_PREFIX="Represent this sentence for searching relevant passages: "`), set `INDEXER_ONNX_MODEL`, and run `python benchmarks/run_benchmark.py --compare benchmarks/baseline.json`.
8
9
  Evidence: `synonym` Recall@5 is only 0.30 (hashing embedder plus the dictionary, which cannot cover free paraphrase). Whether a real model fixes this is not measured.
9
10
  2. **Resolve `FROM a JOIN b ON ..., c`** (a comma list after a JOIN loses `c`), non-sink SQL proc-name literals (`Run("usp_X")`), and EF query tracing (only the table mapping is recorded now).
@@ -1,3 +1,3 @@
1
1
  """Universal Local Codebase Indexer & Context Provider (see index.md)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -58,8 +58,25 @@ def build_parser() -> argparse.ArgumentParser:
58
58
  return p
59
59
 
60
60
 
61
+ def _fix_stdio(serving: bool) -> None:
62
+ """MCP speaks UTF-8 JSON; the Windows locale codec (cp950, cp1252) would garble Chinese queries. On a console,
63
+ keep its encoding but never crash on a character it cannot show."""
64
+ for name in ("stdin", "stdout", "stderr"):
65
+ stream = getattr(sys, name, None)
66
+ if stream is None or not hasattr(stream, "reconfigure"):
67
+ continue
68
+ try:
69
+ if serving:
70
+ stream.reconfigure(encoding="utf-8", errors="replace")
71
+ elif name != "stdin":
72
+ stream.reconfigure(errors="replace")
73
+ except (OSError, ValueError):
74
+ pass
75
+
76
+
61
77
  def main(argv: list[str] | None = None) -> int:
62
78
  args = build_parser().parse_args(argv)
79
+ _fix_stdio(serving=args.cmd == "serve")
63
80
  root = os.path.abspath(args.root)
64
81
 
65
82
  if args.cmd == "serve":
@@ -61,7 +61,7 @@ def git_info(root: str) -> dict | None:
61
61
  """HEAD commit, branch and number of uncommitted paths; None outside a git work tree."""
62
62
  def run(*args: str) -> str | None:
63
63
  try:
64
- r = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True, timeout=15)
64
+ r = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15)
65
65
  except (OSError, subprocess.SubprocessError):
66
66
  return None
67
67
  return r.stdout if r.returncode == 0 else None
@@ -94,6 +94,7 @@ class CodebaseTools:
94
94
  terms.append(t)
95
95
  ranked: dict[str, list[int]] = {"keyword": [], "symbol": [], "vector": []}
96
96
  ranks_override: dict[str, dict[int, int]] = {}
97
+ exact_ids: set[int] = set() # chunks whose symbol name contains a full identifier typed in the query
97
98
 
98
99
  # keyword layer: FTS5 / bm25 over chunk text
99
100
  if terms:
@@ -125,6 +126,19 @@ class CodebaseTools:
125
126
  ):
126
127
  if _has_word(t, r["file_path"]):
127
128
  scores[r[0]] += 1
129
+ # a full identifier typed by the user (SFC_MLot) must match names containing it whole, whatever the
130
+ # case or underscores (SFC_Mlot, SFC_MLotRepository); sub-word matches alone would favour every *Lot
131
+ for ident in self._whole_identifiers(query):
132
+ squashed = re.sub(r"[^a-z0-9]", "", ident.lower())
133
+ like = f"%{squashed}%"
134
+ for r in self.conn.execute(
135
+ "SELECT c.rowid FROM chunk_metadata c JOIN symbols s ON s.id = c.symbol_id "
136
+ "WHERE replace(lower(s.symbol_name), '_', '') LIKE ? LIMIT 500", (like,)):
137
+ scores[r[0]] += 3
138
+ exact_ids.add(r[0])
139
+ for r in self.conn.execute(
140
+ "SELECT rowid FROM chunk_metadata WHERE replace(lower(file_path), '_', '') LIKE ? LIMIT 300", (like,)):
141
+ scores[r[0]] += 2
128
142
  ordered = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:pool]
129
143
  ranked["symbol"] = [rid for rid, _ in ordered]
130
144
  # equal match counts must share a rank, otherwise arbitrary row order decides
@@ -154,13 +168,26 @@ class CodebaseTools:
154
168
  fused[rid] += LAYER_WEIGHTS[name] / (RRF_K + rank)
155
169
  via[rid].append(name)
156
170
  top_ids = [rid for rid, _ in sorted(fused.items(), key=lambda kv: -kv[1])]
171
+ if exact_ids: # a typed identifier beats sub-word noise (every *Lot class), ties keep the fused order
172
+ top_ids = [r for r in top_ids if r in exact_ids] + [r for r in top_ids if r not in exact_ids]
157
173
 
158
174
  results, seen_spans = [], set()
159
- for acc in self._route_data_access(query)[:limit]:
175
+ routed, bare = self._route_data_access(query)
176
+ routed_hits = []
177
+ for acc in routed[:limit]:
160
178
  hit = self._chunk_result(acc)
161
179
  if hit is not None and (hit["file_path"], hit["start_line"], hit["end_line"]) not in seen_spans:
162
180
  seen_spans.add((hit["file_path"], hit["start_line"], hit["end_line"]))
163
- results.append(hit)
181
+ routed_hits.append(hit)
182
+ if bare and routed_hits and top_ids:
183
+ # only a name was typed: the definition (best ordinary match) first, then who touches it
184
+ first = self._ordinary_hit(top_ids[0], fused, via)
185
+ if first is not None:
186
+ span = (first["file_path"], first["start_line"], first["end_line"])
187
+ routed_hits = [h for h in routed_hits if (h["file_path"], h["start_line"], h["end_line"]) != span]
188
+ results.append(first)
189
+ seen_spans.add(span)
190
+ results.extend(routed_hits)
164
191
  for rid in top_ids:
165
192
  if len(results) >= limit:
166
193
  break
@@ -194,14 +221,39 @@ class CodebaseTools:
194
221
  return {"query": query, "results": results, "related_files": self._related_files(results[:3])}
195
222
 
196
223
  # ------------------------------------------------------------------ data access routing
224
+ def _ordinary_hit(self, rid: int, fused: dict, via: dict) -> dict | None:
225
+ row = self.conn.execute(
226
+ "SELECT c.file_path, c.start_line, c.end_line, c.raw_text, s.symbol_type, s.qualified_name, s.signature "
227
+ "FROM chunk_metadata c LEFT JOIN symbols s ON s.id = c.symbol_id WHERE c.rowid=?", (rid,)).fetchone()
228
+ if row is None:
229
+ return None
230
+ lines = textwrap.dedent(row["raw_text"].rstrip()).split("\n")
231
+ return {"file_path": row["file_path"], "start_line": row["start_line"], "end_line": row["end_line"],
232
+ "symbol": row["qualified_name"], "symbol_type": row["symbol_type"], "signature": row["signature"],
233
+ "score": round(fused[rid], 5), "matched_by": via[rid],
234
+ "snippet": "\n".join(lines[:12]) + ("\n..." if len(lines) > 12 else "")}
235
+
236
+ @staticmethod
237
+ def _whole_identifiers(query: str) -> list[str]:
238
+ """Identifier-looking tokens (underscore, digit or camelCase inside) of at least 5 characters."""
239
+ out = []
240
+ for ident in re.findall(r"[A-Za-z_][\w$]*", query):
241
+ if len(ident) >= 5 and (re.search(r"[_\d]", ident[1:]) or
242
+ (re.search(r"[a-z]", ident) and re.search(r"[A-Z]", ident[1:]))):
243
+ out.append(ident)
244
+ return out
245
+
197
246
  _OBJECT_HINT = re.compile(r"\b(table|tables|procedure|procedures|proc|stored|view|views|sp)\b|資料表|預存程序|存储过程|数据表", re.I)
198
247
 
199
- def _route_data_access(self, query: str) -> list[dict]:
248
+ def _route_data_access(self, query: str) -> tuple[list[dict], bool]:
200
249
  """Queries such as "what writes to MES_Lot" name a known table / procedure and an intent. A ranked chunk
201
250
  list cannot enumerate every writer, so those queries get the accessors from the data access index first."""
202
251
  intents = dataflow.detect_intents(query)
252
+ bare = False
203
253
  if not intents:
204
- return []
254
+ if len(query.split()) > 3:
255
+ return [], False
256
+ intents, bare = {"touch"}, True
205
257
  keys = dataflow.known_object_keys(self.conn)
206
258
  hinted = bool(self._OBJECT_HINT.search(query))
207
259
  found: list[str] = []
@@ -218,7 +270,9 @@ class CodebaseTools:
218
270
  if sig not in seen:
219
271
  seen.add(sig)
220
272
  out.append(acc)
221
- return out
273
+ if bare: # leave room for the ordinary name matches (the class, the repository, the file)
274
+ out = out[:3]
275
+ return out, bare
222
276
 
223
277
  def _chunk_result(self, acc: dict) -> dict | None:
224
278
  conn = self.conn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: local-codebase-indexer
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Local codebase indexer and context provider for AI coding assistants: MCP server and CLI with SQL data-access and C# call relations. No cloud, no server.
5
5
  Author: sfy257000
6
6
  Project-URL: Homepage, https://github.com/sfy257000/local-codebase-indexer
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/sfy257000/local-codebase-indexer",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.3.0",
10
+ "version": "0.3.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "local-codebase-indexer",
15
- "version": "0.3.0",
15
+ "version": "0.3.2",
16
16
  "runtimeHint": "uvx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -112,3 +112,33 @@ def test_synonym_expansion_and_project_override(tmp_path):
112
112
  e2 = Expander(str(tmp_path))
113
113
  assert "wpnlot" in e2.expand("在製批 查詢")
114
114
  assert "proc" not in e2.expand("SP list")
115
+
116
+
117
+ def test_bare_table_name_finds_its_repository_not_every_lot_class(tmp_path):
118
+ """Real-world miss: searching SFC_MLot ranked OutsourceLot / PNLToPCSLot above SFC_MLotRepository because the
119
+ sub-word 'lot' matched everything, and the table was spelled SFC_Mlot in the SQL string."""
120
+ (tmp_path / "Repo").mkdir()
121
+ (tmp_path / "Models").mkdir()
122
+ (tmp_path / "Repo" / "SFC_MLotRepository.cs").write_text("""using System.Threading.Tasks;
123
+ using Dapper;
124
+ namespace E {
125
+ public class SFC_MLotRepository : ISFC_MLotRepository {
126
+ private readonly System.Data.IDbConnection _db;
127
+ public Task<int> Deduct(decimal qty) {
128
+ string sql = @" UPDATE SFC_Mlot SET Qty = Qty - @qty WHERE PostFlag = 'W'; DELETE FROM SFC_Mlot WHERE Qty <= 0;";
129
+ return _db.ExecuteAsync(sql, new { qty });
130
+ }
131
+ }
132
+ public interface ISFC_MLotRepository { Task<int> Deduct(decimal qty); }
133
+ }
134
+ """, encoding="utf-8")
135
+ lots = "".join(f"public class Lot{i} : SFCLot {{ }} public class ReworkLot{i} : SFCLot {{ }}\n" for i in range(8))
136
+ (tmp_path / "Models" / "SFCLot.cs").write_text(
137
+ "namespace E { public partial class SFCLot { }\n" + lots + "public class OutsourceLot : SFCLot { } }", encoding="utf-8")
138
+ t = CodebaseTools(str(tmp_path))
139
+ for q in ("SFC_MLot", "SFC_Mlot", "sfc_mlot"):
140
+ top = t.semantic_search(q, limit=5)["results"]
141
+ assert top[0]["file_path"].endswith("SFC_MLotRepository.cs"), q
142
+ assert sum(r["file_path"].endswith("SFC_MLotRepository.cs") for r in top) >= 3, q
143
+ assert any((r.get("data_access") or {}).get("access") == "write" for r in top), q
144
+ t.indexer.close()
@@ -330,3 +330,30 @@ def test_benchmark_corpus_is_valid_and_floor_holds(tmp_path):
330
330
  cats = json.loads(out.read_text(encoding="utf-8"))["suites"]["synthetic"]["categories"]
331
331
  assert cats["exact_identifier"]["recall5"] == 1.0
332
332
  assert cats["chinese"]["recall5"] == 1.0
333
+
334
+
335
+ def test_git_output_is_decoded_as_utf8_not_the_locale(tmp_path, monkeypatch):
336
+ """On Windows the locale codec (cp950) cannot decode git's UTF-8 output and the reader thread crashed."""
337
+ import subprocess
338
+
339
+ import local_codebase_indexer.core as core
340
+ seen = {}
341
+ real = subprocess.run
342
+
343
+ def spy(*a, **kw):
344
+ seen.update(kw)
345
+ return real(*a, **kw)
346
+
347
+ monkeypatch.setattr(subprocess, "run", spy)
348
+ core.git_info(str(tmp_path))
349
+ assert seen.get("encoding") == "utf-8" and seen.get("errors") == "replace"
350
+
351
+
352
+ def test_mcp_serve_reads_utf8_even_when_locale_is_cp950(project):
353
+ q = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
354
+ "name": "codebase_semantic_search", "arguments": {"query": "批號 LotNo", "limit": 2}}}, ensure_ascii=False)
355
+ env = dict(os.environ, PYTHONIOENCODING="cp950")
356
+ r = subprocess.run([sys.executable, "-m", "local_codebase_indexer", "--root", str(project), "serve"],
357
+ input=(q + "\n").encode("utf-8"), capture_output=True, env=env, cwd=ROOT, timeout=60)
358
+ out = json.loads(r.stdout.decode("utf-8"))
359
+ assert "result" in out and "error" not in out, r.stderr.decode("utf-8", "replace")