diff --git a/CHANGELOG.md b/CHANGELOG.md index 796caf2ac4..d30dcfb646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **T-SQL / SQL Server DDL kinds now surface as symbols (#230)** — The `SymbolExtractor` SQL row previously only captured `CREATE TABLE` / `(MATERIALIZED) VIEW` / `PROCEDURE` / `FUNCTION` / `TRIGGER` / `TYPE` / `SCHEMA` / `SEQUENCE` / `DOMAIN` / `EXTENSION` / `INDEX` and `ALTER TABLE`, so on T-SQL / SQL Server migration scripts the short-form `CREATE PROC`, T-SQL `CREATE OR ALTER` (SQL Server 2016+), `CREATE SYNONYM`, `CREATE DATABASE` / `LOGIN` / `USER` / `ROLE` / `CERTIFICATE`, `CREATE PARTITION FUNCTION` / `PARTITION SCHEME`, `CREATE FULLTEXT CATALOG`, and every non-`TABLE` `ALTER` form (procedures, views, sequences, synonyms, principals, etc.) were silently dropped from the index — making `symbols` / `definition` / `outline` / `inspect` look near-empty on enterprise .NET backends whose primary surface is a stored-procedure or schema-migration repo. The TABLE/VIEW and PROCEDURE/PROC/FUNCTION/TRIGGER rows now accept `CREATE OR (REPLACE|ALTER)`, the procedure row accepts the `PROC` synonym, dedicated rows were added for `CREATE SYNONYM` (class), `CREATE DATABASE|LOGIN|USER|ROLE|CERTIFICATE` (class), `CREATE PARTITION FUNCTION` (function) / `CREATE PARTITION SCHEME` (class), `CREATE FULLTEXT CATALOG` (class), and the `ALTER` side is split into kind-consistent rows so procedure-like `ALTER` (`PROCEDURE` / `PROC` / `FUNCTION` / `TRIGGER` / `PARTITION FUNCTION`) emits kind `function`, `ALTER SCHEMA` emits kind `namespace`, `ALTER EXTENSION` emits kind `import`, and the remaining object kinds (`TABLE`, `(MATERIALIZED) VIEW`, `SEQUENCE`, `SYNONYM`, `LOGIN`, `USER`, `ROLE`, `DATABASE`, `CERTIFICATE`, `INDEX`, `TYPE`, `DOMAIN`, `PARTITION SCHEME`, `FULLTEXT CATALOG`) emit kind `class`. Splitting the `ALTER` row by kind keeps the same object's `CREATE` and `ALTER` symbols under the same `kind` so `symbols --kind function` / `definition` / `inspect` do not duplicate across kinds. The `ALTER` side also now picks up `CERTIFICATE`, `DOMAIN`, and `EXTENSION` that were missing from the original row so every non-`TABLE` `ALTER` form is actually captured rather than just claimed. Every SQL row now shares an identifier shape that accepts PG double-quoted (`"name"`), T-SQL bracketed (`[name]`), or bare (`\w+`) tokens with optional dotted qualifiers such as `[dbo].[sp_X]`, `"schema"."name"`, and `schema.name`. The existing `CREATE INDEX ON tbl` / anonymous-name guard (`(?!ON\b)`), the `CREATE TYPE ... AS ENUM` Postgres branch, and the `CREATE SCHEMA AUTHORIZATION` branch are preserved. Body-less `namespace`-kind symbols from SQL (`CREATE SCHEMA sales;`, `ALTER SCHEMA sales TRANSFER ...;`) would previously trip the `IsFileScopedNamespace` check that wraps every subsequent top-level symbol under a C# `namespace X;` file-scoped declaration — so `ALTER FUNCTION dbo.fn_Total`, `ALTER PARTITION FUNCTION pf_OrdersByYear`, and every later declaration in the same file were emitted with `container_kind='namespace'` / `container_name='sales'`, breaking `definition` / `inspect` / family grouping on real migration scripts. The helper now additionally requires the signature to start with the `namespace` keyword, so SQL schema rows no longer act as file-scoped containers; SQL schema scoping is still expressed through qualified names (`sales.Money`), matching how the rest of cdidx reads SQL. Added a dedicated `Extract_SQL_DetectsTSqlDdlKinds` regression test that locks all thirteen new T-SQL `CREATE` shapes and every new `ALTER` kind contract (`ALTER PROCEDURE` / `ALTER FUNCTION` / `ALTER PARTITION FUNCTION` → `function`; `ALTER SCHEMA` → `namespace`; `ALTER EXTENSION` → `import`; `ALTER CERTIFICATE` / `ALTER DOMAIN` → `class`) including the `CREATE PROC` + `ALTER PROCEDURE` pair that must emit two `dbo.sp_DailyReport` rows both under kind `function`, and asserts that no later top-level SQL symbol is wrapped under `namespace=sales` so the file-scoped-namespace container pollution cannot regress. `GO` batch boundary awareness and `BEGIN ... END` structural nesting are deliberately out of scope here and remain filed in #230 as stretch items. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. Closes #230. #### Fixed +- **CJK queries no longer return 0 results against longer FTS tokens (#198)** — `fts_chunks` is created with FTS5's default `unicode61` tokenizer, which has no rule for splitting runs of adjacent CJK / Japanese / Chinese / Korean codepoints, so a multi-character CJK word like `計算する` is stored as a single FTS token. The literal-safe search path used to wrap every whitespace-separated query token in `"..."` phrase quotes without any prefix designator, so `cdidx search 計算` became `MATCH '"計算"'` and returned 0 matches against any chunk containing `計算する`, even though the text was clearly present — including against cdidx's own Japanese documentation (CLAUDE.md / README / CHANGELOG Japanese sections). `SanitizeFtsQuery` now routes each token through a new `FormatFtsToken` helper that detects CJK script codepoints via `Rune.EnumerateRunes()` and emits the token as an FTS5 prefix phrase (`"計算"*`) instead of a bare phrase, so `search 計算` now finds `def 計算する(値):` while pure-ASCII queries keep their existing exact-phrase semantics unchanged. The CJK set covers: Hiragana + Katakana + phonetic extensions + Kana Supplement + Kana Extended-A + Small Kana Extension (`U+1B000..U+1B16F`) + Kana Extended-B (`U+1AFF0..U+1AFFF`, Unicode 15.0); Bopomofo and Bopomofo Extended (Chinese phonetic / zhuyin, `U+3100..U+312F` and `U+31A0..U+31BF`); Han-script codepoints outside the Unified Ideographs blocks — ideographic iteration mark `々` (`U+3005`), closing mark `〆` (`U+3006`), number zero `〇` (`U+3007`), Hangzhou numerals (`U+3021..U+3029`), vertical kana repeat marks (`U+3031..U+3035`), and Hangzhou 10/20/30 + vertical iteration marks (`U+3038..U+303B`); CJK Unified Ideographs + Extensions A–I + Compatibility; the historical East Asian scripts that Unicode places in or adjacent to the CJK blocks and that `unicode61` keeps as word characters, listed as individual Unicode-block envelopes so regression tests exercise each block separately and a future narrowing of one block cannot drag the others — Yi Syllables (`U+A000..U+A48F`, the Nuosu syllabary), Tangut (`U+17000..U+187FF`), Tangut Components (`U+18800..U+18AFF`), Khitan Small Script (`U+18B00..U+18CFF`), Tangut Supplement (`U+18D00..U+18D8F`), and Nüshu (`U+1B170..U+1B2FF`, the non-BMP women's script historically used in Jiangyong County, Hunan); the individual iteration / annotation codepoints in the Ideographic Symbols and Punctuation block (`U+16FE0..U+16FF1`) that annotate Han-based historical text — Tangut Iteration Mark `U+16FE0` (`Lm`), Nüshu Iteration Mark `U+16FE1` (`Lm`), Old Chinese Iteration Mark `U+16FE3` (`Lm`), Khitan Small Script Filler `U+16FE4` (`Mn`), and Vietnamese Chu Nom Alternate Reading Marks CA / NHAY `U+16FF0..U+16FF1` (`Mc`) — with `U+16FE2` (Old Chinese Hook Mark, `Po`) intentionally excluded because `unicode61` drops it during tokenization, and Yi Radicals (`U+A490..U+A4CF`) intentionally excluded because every codepoint there is Unicode category `OtherSymbol` (`So`) and is dropped by `unicode61` during tokenization. `OtherNotAssigned` (`Cn`) is deliberately not excluded at the top: .NET's Unicode tables lag the Unicode Consortium's releases, so codepoints like the Extension I range `U+2EBF0..U+2EE5F` (Unicode 15.1, assigned 2023) still report `Cn` on .NET 8 — rejecting `Cn` up front would silently regress real CJK codepoints just because the runtime has not caught up. SQLite's bundled `unicode61` tokenizer also ships its own (different and older) Unicode tables, and empirical verification through the locally built binary on .NET 8 confirms that codepoints which .NET reports as `Cn` inside the block envelopes above — including Tangut's reserved `U+187F8..U+187FF`, Khitan's reserved `U+18CD6..U+18CFF`, and Tangut Supplement's reserved `U+18D09..U+18D8F` — are still kept as word characters by `unicode61` during indexing. Allowing these codepoints through the block-envelope branches therefore produces correct behavior: if a file genuinely contains `abc`, `search ` returns that file; if it does not, FTS5 simply returns zero rows. That is the same #198 invariant that protects the assigned codepoints; Hangul syllables + Jamo blocks; and Halfwidth forms from halfwidth Katakana through halfwidth Hangul (`U+FF65..U+FFDC`). The predicate is deliberately CJK-scoped rather than "any non-ASCII codepoint": symbol Unicode categories (`OtherSymbol` / `MathSymbol` / `CurrencySymbol` / `ModifierSymbol`) are excluded up front so an emoji-adjacent CJK block cannot trigger fallback, and Latin-diacritic (`naïve`, `café`), Greek, Cyrillic, and emoji-mixed tokens skip the CJK prefix path. The narrowing matters because `unicode61` tokenizes Latin-diacritic normally (so prefix fallback would silently widen `naïve` to `naïvety` / `naïveness`), and `unicode61` also drops symbol codepoints entirely — promoting `foo🎉` to `"foo🎉"*` would be parsed by FTS5 as `"foo"*` and leak into unrelated ASCII neighbors like `foobar`, which an earlier round of this fix had actually regressed before codex review caught it. The halfwidth range intentionally stops at `U+FFDC` because `U+FFDD..U+FFDF` are unassigned and `U+FFE0..U+FFEE` are fullwidth currency / arrow symbols that `unicode61` drops during tokenization, while fullwidth Latin / digits (`ABC`, `012`) sit earlier in the block and keep exact-phrase semantics. CJK Radicals Supplement (`U+2E80..U+2EFF`) and Kangxi Radicals (`U+2F00..U+2FDF`) are intentionally excluded from the CJK set because every codepoint in those blocks is Unicode category `OtherSymbol` (`So`) and is therefore dropped by `unicode61` during tokenization — prefix fallback cannot help them even if enabled. The same path is shared by `search`, the `--count` fast path (`CountSearchResults`), and the MCP `search` tool, so CLI and MCP consumers get the fix together. Important FTS-level semantics that callers should be aware of (documented rather than "fixed" here): (i) CJK tokens always take the prefix path, so `search 計算する` also returns chunks containing `計算する追加` — this is the intentional trade-off that makes `search 計算` work, and strict equality requires `--exact` / `instr`; (ii) emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer because `unicode61` strips the emoji on both the index side and the query side (`foo🎉` is indexed and queried as the token `foo`), so `search foo🎉` will match a file containing `def foo():` — strict emoji-bearing equality again requires `--exact`. Added `DbReaderTests` coverage for CJK substring matches via both `Search` and `CountSearchResults`, a positive pin that CJK full-token queries still find exact full-token matches, a paired pin that locks the intentional CJK full-token widening so it cannot be silently reverted into the #198 zero-hit repro, a "CJK prefix does not match unrelated CJK tokens" regression so `search 計算` never leaks into `検索する`, an emoji-mixed regression that pins `search foo🎉` against prefix widening into `foobar`, a companion emoji-mixed pin that documents the `foo🎉` / `foo` FTS indistinguishability and proves the `--exact` path does distinguish them, a Latin-diacritic regression that pins `search naïve` against widening into `naïvety`, non-BMP regressions for CJK Extension H (`U+31350`) and Extension I (`U+2EBF0`) so a predicate that walked chars instead of runes or forgot either range would fail its own dedicated test, and additional regressions for `々` (`U+3005`), `〇` (`U+3007`), halfwidth Hangul (`ᄆ` `U+FFA1`), Kana Extended-B (`𚿰` `U+1AFF0`), vertical kana repeat marks (`〱` `U+3031`), Bopomofo (`ㄅ` `U+3105`), Bopomofo Extended (`ㆠ` `U+31A0`), Yi Syllables (`ꀀ` `U+A000`), Tangut (`𗀀` `U+17000`), Tangut Components (`𘠀` `U+18800`), Khitan Small Script (`𘬀` `U+18B00`), Tangut Supplement (`𘴀` `U+18D00`), Nüshu (`𛅰` `U+1B170`), and the Ideographic Symbols and Punctuation iteration / annotation marks — Tangut Iteration Mark (`𖿠` `U+16FE0`, `Lm`), Khitan Small Script Filler (`𖿤` `U+16FE4`, `Mn`), and Vietnamese Chu Nom Alternate Reading Mark CA (`𖿰` `U+16FF0`, `Mc`) — each pinning `search ` against a chunk containing `abc` to lock the #198 zero-hit shape on every new codepoint class we brought into the CJK set. The four Tangut-family tests (Tangut, Tangut Components, Khitan Small Script, Tangut Supplement) each exercise their own predicate branch so a future rewrite that collapses two of these blocks together breaks its own dedicated test first, and the three Ideographic Symbols and Punctuation tests span the `Lm`, `Mn`, and `Mc` Unicode categories so no individual annotation-category regression can silently slip past. Known follow-up (out of scope for this fix): emoji substring search (`search 🎉`) is still 0-result because the default `unicode61` categories `L* N* Co` exclude `S*` (symbols) entirely, so emoji are dropped by the tokenizer before any query ever runs. That requires a schema-level tokenizer change (issue #198 Option A/B) and is tracked separately. Affected: `src/CodeIndex/Database/DbSearchReader.cs`, `src/CodeIndex/Mcp/McpToolDefinitions.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`, `CLAUDE.md`. Closes #198. - **Regression lock for C# interpolated and verbatim string reference extraction (#264)** — The original issue report alleged that `ReferenceExtractor` hid call-site references inside C# `$"..."` interpolation holes and leaked phantom references from `@"..."` verbatim string bodies. On the current `StructuralLineMasker` implementation the C# paths already handle single-line `$"..."`, multi-line `$@"..."` / `@$"..."`, and non-interpolated `@"..."` correctly — interpolation-hole call sites are captured with correct `ContainerName`, and multi-line verbatim bodies never leak phantom edges — but no regression test pinned that behavior end-to-end for the exact fixture in the issue report, so any future refactor of the masker's C# frame-stack handling could silently reintroduce the bug. Added `Extract_CsharpInterpolatedAndVerbatimStrings_Issue264_Repro_CapturesHoleCallsAndSuppressesPhantoms` which feeds the issue's exact fixture (`$"Hello {Helper.GetName()}"`, `$"Age: {Helper.GetAge()} years"`, `$"Nested {Helper.Format(Helper.GetName())}"`, a multi-line `$@"..."` with an interpolation hole, a plain non-interpolated call site, and a multi-line `@"..."` verbatim body containing `PhantomCall()` / `PhantomTable()` / `MoreFake()`) through `ReferenceExtractor.Extract` and pins four invariants at once: `Helper.GetName` is captured exactly four times, `Helper.GetAge` and `Helper.Format` are each captured at least once, every captured call's `ContainerName` resolves to `Work`, and no reference row ever surfaces for the phantom identifiers embedded in the verbatim body. Affected: `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`. Closes #264. - **C# nested `new interface` (member-hiding a base-class nested interface) is now pinned by a dedicated regression (#376)** — Issue #376 reported that `public new interface INested { ... }` nested inside a derived class was silently dropped because the C# interface regex modifier list was `(?:partial|unsafe)` and did not include `new`. The free-modifier-order rewrite for #355 already widened the list to `(?:partial|unsafe|file|new)`, so the current binary already emits two `interface INested` symbols — one nested in `Base`, one nested in `Derived` — and the #376 repro is no longer reproducible. This change locks that behavior in with a dedicated `Extract_CSharp_NewNestedInterface_MemberHiding` regression that runs the exact fixture from the issue and asserts both nested interfaces are extracted (`ContainerName == "Base"` and `ContainerName == "Derived"`), so a future modifier-list edit cannot regress the #376 case without a failing test. Affected: `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. Closes #376. - **C# 8 `readonly` instance indexers on structs are now captured as `Item` symbols (#352)** — `SymbolExtractor` previously dropped `public readonly int this[int i] => _arr[i];` and `public readonly string this[string key] { get => key; }` silently, so `definition`, `symbols`, `outline`, and `inspect` undercounted `readonly`-heavy slice / span / immutable-wrapper struct surfaces. The indexer row already accepts `readonly` in the free-order modifier slot alongside `unsafe` / `extern` / `ref (readonly)` following the #355 rewrite, and this change locks that behavior in with a dedicated `Extract_CSharp_DetectsReadonlyIndexers` regression covering expression-body, block-body, and generic-return (`public readonly T this[int i] { get => _items[i]; }`) shapes plus non-readonly / readonly-method baselines so a future regex refactor cannot regress the #352 case without a failing test. Affected: `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. @@ -716,6 +717,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **T-SQL / SQL Server の DDL 種別をシンボルとして拾えるようにした (#230)** — `SymbolExtractor` の SQL 行は従来 `CREATE TABLE` / `(MATERIALIZED) VIEW` / `PROCEDURE` / `FUNCTION` / `TRIGGER` / `TYPE` / `SCHEMA` / `SEQUENCE` / `DOMAIN` / `EXTENSION` / `INDEX` と `ALTER TABLE` しか捕捉しておらず、T-SQL / SQL Server のマイグレーション系スクリプトでは短縮形 `CREATE PROC`、T-SQL の `CREATE OR ALTER`(SQL Server 2016+)、`CREATE SYNONYM`、`CREATE DATABASE` / `LOGIN` / `USER` / `ROLE` / `CERTIFICATE`、`CREATE PARTITION FUNCTION` / `PARTITION SCHEME`、`CREATE FULLTEXT CATALOG`、および `TABLE` 以外の `ALTER`(プロシージャ、ビュー、シーケンス、シノニム、プリンシパル等)がすべてインデックスから黙って落ちており、ストアドプロシージャやスキーマ・マイグレーション中心のエンタープライズ .NET バックエンドでは `symbols` / `definition` / `outline` / `inspect` がほぼ空に見えてしまっていた。TABLE/VIEW 行と PROCEDURE/PROC/FUNCTION/TRIGGER 行を `CREATE OR (REPLACE|ALTER)` 対応に拡張し、プロシージャ行に `PROC` 短縮形を追加した。`CREATE SYNONYM`(class)、`CREATE DATABASE|LOGIN|USER|ROLE|CERTIFICATE`(class)、`CREATE PARTITION FUNCTION`(function)/ `CREATE PARTITION SCHEME`(class)、`CREATE FULLTEXT CATALOG`(class)の専用行を追加し、`ALTER` 側も kind を揃える形で分割した — プロシージャ類の `ALTER`(`PROCEDURE` / `PROC` / `FUNCTION` / `TRIGGER` / `PARTITION FUNCTION`)は `function`、`ALTER SCHEMA` は `namespace`、`ALTER EXTENSION` は `import`、残りのオブジェクト種別(`TABLE`、`(MATERIALIZED) VIEW`、`SEQUENCE`、`SYNONYM`、`LOGIN`、`USER`、`ROLE`、`DATABASE`、`CERTIFICATE`、`INDEX`、`TYPE`、`DOMAIN`、`PARTITION SCHEME`、`FULLTEXT CATALOG`)は `class` を返す。`ALTER` を kind ごとに分けることで、同一オブジェクトの `CREATE` と `ALTER` が同じ `kind` に揃い、`symbols --kind function` / `definition` / `inspect` が種別またぎで重複しない。`ALTER` 側には元の行で漏れていた `CERTIFICATE` / `DOMAIN` / `EXTENSION` も追加し、`TABLE` 以外の `ALTER` を本当に網羅する形にした。SQL の全行が共通の識別子形状を共有し、PG の `"name"`、T-SQL の `[name]`、裸 `\w+` トークンをドット修飾できる形(`[dbo].[sp_X]` / `"schema"."name"` / `schema.name`)を全行で受け入れる。既存の `CREATE INDEX ON tbl` 無名ガード(`(?!ON\b)`)、Postgres の `CREATE TYPE ... AS ENUM` 分岐、`CREATE SCHEMA AUTHORIZATION` 分岐は維持している。さらに、SQL の `CREATE SCHEMA sales;` / `ALTER SCHEMA sales TRANSFER ...;` のような body 無しの `namespace` kind 行が、C# の `namespace X;` file-scoped namespace を想定した `IsFileScopedNamespace` 判定を発火させ、以降のトップレベルシンボル全部を `container_kind='namespace'` / `container_name='sales'` で包んでしまう不具合を修正した(`ALTER FUNCTION dbo.fn_Total` や `ALTER PARTITION FUNCTION pf_OrdersByYear` の container が汚染され、`definition` / `inspect` / family grouping が崩れていた)。`IsFileScopedNamespace` は signature が `namespace` キーワードで始まる行だけを対象とするよう追加ガードを入れ、SQL の schema は file-scoped container にならないようにした(SQL では schema スコープは `sales.Money` のような修飾名で表現する方針を維持)。`Extract_SQL_DetectsTSqlDdlKinds` 回帰テストを追加し、新規 13 形の `CREATE` に加え、新しい `ALTER` の kind 契約(`ALTER PROCEDURE` / `ALTER FUNCTION` / `ALTER PARTITION FUNCTION` → `function`、`ALTER SCHEMA` → `namespace`、`ALTER EXTENSION` → `import`、`ALTER CERTIFICATE` / `ALTER DOMAIN` → `class`)もすべて固定し、`CREATE PROC` と `ALTER PROCEDURE` のペアで `dbo.sp_DailyReport` が同じ `function` kind で 2 件出ることも含めて押さえ、後続のトップレベルシンボルが `namespace=sales` 配下に吸い込まれないことも固定した。`GO` バッチ境界や `BEGIN ... END` の構造ネストは本 issue のストレッチ項目として意図的に対象外。対象: `src/CodeIndex/Indexer/SymbolExtractor.cs`、`tests/CodeIndex.Tests/SymbolExtractorTests.cs`。Closes #230。 #### 修正 +- **CJK クエリが長い FTS トークン内の部分一致で 0 件に落ちないよう修正 (#198)** — `fts_chunks` は FTS5 既定の `unicode61` トークナイザで作成されるが、unicode61 は隣接する CJK / 日本語 / 中国語 / 韓国語のコードポイント列を区切るルールを持たないため、`計算する` のような多文字 CJK 語は FTS 上では 1 トークンとして保存される。literal-safe 検索経路はこれまで空白区切りの各クエリトークンを prefix 指定子なしの `"..."` フレーズで囲むだけだったため、`cdidx search 計算` は `MATCH '"計算"'` となり、`計算する` を含むチャンクに対して 0 件を返していた(cdidx 自身の日本語ドキュメント、すなわち CLAUDE.md / README / CHANGELOG の日本語セクションを検索しても同様に 0 件だった)。`SanitizeFtsQuery` は新ヘルパー `FormatFtsToken` を経由するようになり、`Rune.EnumerateRunes()` で CJK スクリプトを検出したトークンを素のフレーズではなく FTS5 prefix phrase `"計算"*` として発行する。これにより `search 計算` が `def 計算する(値):` を取り戻す一方、純 ASCII クエリの既存 exact-phrase セマンティクスは変わらない。CJK セットは以下をカバーする: ひらがな + カタカナ + 音声拡張 + Kana Supplement + Kana Extended-A + Small Kana Extension (`U+1B000..U+1B16F`) + Kana Extended-B (`U+1AFF0..U+1AFFF`、Unicode 15.0)、注音符号(ボポモフォ)と拡張注音符号(中国語発音、`U+3100..U+312F` と `U+31A0..U+31BF`)、CJK 統合漢字範囲外の Han script コードポイント — 漢字反復記号 `々` (`U+3005`)、閉じ記号 `〆` (`U+3006`)、漢数字ゼロ `〇` (`U+3007`)、Hangzhou 数字 (`U+3021..U+3029`)、縦書き仮名反復記号 (`U+3031..U+3035`)、Hangzhou 10/20/30 と縦書き反復記号 (`U+3038..U+303B`)、CJK 統合漢字 + 拡張 A–I + 互換、Unicode 上で CJK ブロックに隣接配置され `unicode61` が単語文字として扱う東アジアの歴史的文字。各ブロックを Unicode ブロック単位で独立した範囲として列挙し、将来いずれかを狭めたり広げたりしても他ブロックが巻き添えにならないようにしている — 彝文字音節(Yi Syllables、`U+A000..U+A48F`、中国南西部ノス族の音節文字)、西夏文字(Tangut、`U+17000..U+187FF`)、西夏文字部品(Tangut Components、`U+18800..U+18AFF`)、契丹小字(Khitan Small Script、`U+18B00..U+18CFF`)、西夏文字補助(Tangut Supplement、`U+18D00..U+18D8F`)、女書(Nüshu、`U+1B170..U+1B2FF`、中国湖南省江永県の非 BMP 女性専用音節文字)。さらに Han を基盤とする歴史的テキストに付与される Ideographic Symbols and Punctuation ブロック (`U+16FE0..U+16FF1`) の反復 / 注釈記号も個別に含める — Tangut 反復記号 `U+16FE0`(`Lm`)、Nüshu 反復記号 `U+16FE1`(`Lm`)、Old Chinese 反復記号 `U+16FE3`(`Lm`)、Khitan Small Script filler `U+16FE4`(`Mn`)、ベトナム語 Chu Nom 読み記号 CA / NHAY `U+16FF0..U+16FF1`(`Mc`)。`U+16FE2`(Old Chinese Hook Mark、`Po`)は `unicode61` のトークナイザが drop するため意図的に除外、彝文字部首(Yi Radicals、`U+A490..U+A4CF`)は `OtherSymbol`(`So`)で drop されるため同じく意図的に除外している。`OtherNotAssigned`(`Cn`)は上流の除外に意図的に含めていない。.NET の Unicode テーブルは Unicode Consortium のリリースに遅れるため、例えば Extension I の `U+2EBF0..U+2EE5F`(Unicode 15.1、2023 年割当)は .NET 8 でも依然として `Cn` と報告される。ここで `Cn` を除外すると、ランタイム側のテーブル更新遅延だけを理由に実在の CJK コードポイントが静かに regressions する。SQLite 同梱の `unicode61` トークナイザも .NET とは別の(そしてより古い)Unicode テーブルを持っており、ローカルビルドしたバイナリを .NET 8 で動かして検証した結果、上記ブロック末尾の予約領域(Tangut の `U+187F8..U+187FF`、Khitan の `U+18CD6..U+18CFF`、Tangut Supplement の `U+18D09..U+18D8F` を含む `Cn` 報告のコードポイント)も `unicode61` は indexing 側で単語文字として保持する。これらのコードポイントをブロックベース範囲で prefix 昇格の対象にするのは正しい挙動で、ファイル中に実際に `<予約領域>abc` が存在すれば `search <予約領域>` はそのファイルを返し、存在しなければ FTS5 は単に 0 行を返す — 割当済みコードポイントに対する #198 不変条件と同じ仕組みである。ハングル音節 + Jamo、半角カナから半角ハングルまで (`U+FF65..U+FFDC`)。判定は意図的に「非 ASCII 全般」ではなく「CJK スクリプト限定」に絞ってあり、シンボル系 Unicode カテゴリ(`OtherSymbol` / `MathSymbol` / `CurrencySymbol` / `ModifierSymbol`)は先頭で除外しているため emoji 隣接ブロックは fallback を起こさない。Latin-diacritic(`naïve`、`café`)、ギリシャ、キリル、絵文字混在トークンは CJK prefix 経路を通さない。理由は、unicode61 が Latin-diacritic を通常どおりトークン化するため prefix fallback を掛けると `naïve` が `naïvety` / `naïveness` に静かに広がってしまうこと、および unicode61 が記号コードポイントを丸ごと破棄するため `foo🎉` を `"foo🎉"*` に昇格すると FTS5 側で `"foo"*` と解釈されて `foobar` 等の無関係な ASCII 近傍にまでヒットが漏れてしまうこと(今回の修正の初期ラウンドで実際に起きた回帰で、codex レビューが捕えた)にある。半角形の上限を `U+FFDC` にしているのは、`U+FFDD..U+FFDF` が未割当、`U+FFE0..U+FFEE` が全角通貨/矢印記号で `unicode61` のトークナイザ段階で drop されるため、拾う意味がないから。全角 Latin / 数字(`ABC` / `012`)はブロック内の前方に位置しており、これらは従来どおりプレフィックス拡張されず完全一致のまま保つ。CJK Radicals Supplement(`U+2E80..U+2EFF`)と Kangxi Radicals(`U+2F00..U+2FDF`)は CJK セットから意図的に除外している。両ブロックの全コードポイントは Unicode カテゴリ `OtherSymbol`(`So`)であり `unicode61` のトークナイザ段階で drop されるため、prefix fallback を有効化しても救えないからである。同じ経路は `search` / `--count` 高速路 (`CountSearchResults`) / MCP `search` ツールで共有されるため、CLI と MCP の双方が同時に直る。利用者が留意すべき(ここで「修正」ではなく挙動として文書化する)FTS 層の仕様: (i) CJK トークンは常に prefix 経路を通るため、`search 計算する` は `計算する追加` を含むチャンクも返す。これは `search 計算` が動くための意図的なトレードオフで、厳密一致が必要なら `--exact` / `instr` を使う。(ii) emoji 混在トークンは、`unicode61` が indexing 側でもクエリ側でも emoji を削ぐため、FTS 層では素の ASCII トークンと区別できない(`foo🎉` は両側で `foo` にトークン化される)。したがって `search foo🎉` は `def foo():` を含むファイルもマッチしてしまい、厳密な emoji 付き一致が必要なら `--exact` を使う必要がある。回帰テストとして `DbReaderTests` に `Search` と `CountSearchResults` の両経路における CJK 部分一致テスト、CJK 完全トークンクエリが exact full token をちゃんと取り戻すことを固定する positive pin、CJK 完全トークンが意図的に後続トークンまで広がる挙動を固定して #198 の 0 件再現に黙って戻されないよう縛る paired pin、`search 計算` が無関係な CJK 語(`検索する`)に漏れないことを固定する「CJK prefix 非漏洩テスト」、`search foo🎉` が `foobar` に prefix 拡張しないことを固定する emoji 混在テスト、`foo🎉` と `foo` が FTS 層で区別できないこと・`--exact` 経路なら区別できることを固定する companion pin、`search naïve` が `naïve` だけを拾って `naïvety` を拾わないことを固定する「Latin-diacritic 非拡張テスト」、CJK Extension H (`U+31350`) と Extension I (`U+2EBF0`) の非 BMP 回帰テスト(char 走査している述語やどちらかの範囲を忘れた述語がそれぞれ独立に壊れるよう dedicated に固定)、加えて `々` (`U+3005`)、`〇` (`U+3007`)、半角ハングル (`ᄆ` `U+FFA1`)、Kana Extended-B (`𚿰` `U+1AFF0`)、縦書き仮名反復記号 (`〱` `U+3031`)、ボポモフォ (`ㄅ` `U+3105`)、拡張ボポモフォ (`ㆠ` `U+31A0`)、彝文字音節 (`ꀀ` `U+A000`)、西夏文字 (`𗀀` `U+17000`)、西夏文字部品 (`𘠀` `U+18800`)、契丹小字 (`𘬀` `U+18B00`)、西夏文字補助 (`𘴀` `U+18D00`)、女書 (`𛅰` `U+1B170`)、および Ideographic Symbols and Punctuation の反復 / 注釈記号 — Tangut 反復記号 (`𖿠` `U+16FE0`、`Lm`)、Khitan Small Script filler (`𖿤` `U+16FE4`、`Mn`)、ベトナム語 Chu Nom 読み記号 CA (`𖿰` `U+16FF0`、`Mc`) — それぞれで `search ` が `abc` を含むチャンクに対して 0 件を返さないことを固定する回帰テストを追加し、CJK セットに新規に加えたコードポイント種別それぞれについて #198 の 0 件症状が再発しないよう縛った。Tangut 系 4 ブロック(Tangut / Tangut Components / Khitan Small Script / Tangut Supplement)のテストはそれぞれ別の predicate 分岐を踏むため、将来いずれか 2 つを統合するような書き換えがあっても専用テストが先に壊れるようになっている。Ideographic Symbols and Punctuation の 3 テストは `Lm` / `Mn` / `Mc` のカテゴリをそれぞれ踏むため、いずれかの注釈カテゴリだけが黙って回帰することも防げる。既知の follow-up(今回のスコープ外): 絵文字の部分一致検索 (`search 🎉`) は `unicode61` の既定カテゴリ `L* N* Co` が `S*`(シンボル類)を含まないため、トークナイザ段階で落ちてしまい依然として 0 件。これはスキーマ側のトークナイザ設定変更(issue #198 の Option A/B)が必要で、別 issue として扱う。対象: `src/CodeIndex/Database/DbSearchReader.cs`、`src/CodeIndex/Mcp/McpToolDefinitions.cs`、`tests/CodeIndex.Tests/DbReaderTests.cs`、`CLAUDE.md`。Closes #198。 - **C# 補間文字列 / 逐語文字列に対する参照抽出の回帰テストを追加 (#264)** — issue 原文は `ReferenceExtractor` が C# の `$"..."` 補間ホール内の呼び出しを落とし、`@"..."` 逐語文字列本体から phantom 参照を漏らしていると報告していた。現時点の `StructuralLineMasker` 実装では、単行 `$"..."`、複数行 `$@"..."` / `@$"..."`、非補間 `@"..."` のいずれも C# 経路で正しく処理されており、補間ホール内の呼び出しは正しい `ContainerName` で捕捉され、複数行逐語本体から phantom 参照は漏れない状態になっているが、issue 原文の再現コードそのものを end-to-end で固定する回帰テストが無く、将来 masker の C# フレームスタック処理を refactor した際に静かに不具合が再発しうる状況だった。`Extract_CsharpInterpolatedAndVerbatimStrings_Issue264_Repro_CapturesHoleCallsAndSuppressesPhantoms` を追加し、issue 原文の fixture そのもの(`$"Hello {Helper.GetName()}"`、`$"Age: {Helper.GetAge()} years"`、`$"Nested {Helper.Format(Helper.GetName())}"`、補間ホール付き複数行 `$@"..."`、素の非補間呼び出し、`PhantomCall()` / `PhantomTable()` / `MoreFake()` を含む複数行 `@"..."` 逐語本体)を `ReferenceExtractor.Extract` に通し、(1) `Helper.GetName` がちょうど 4 回捕捉されること、(2) `Helper.GetAge` と `Helper.Format` がそれぞれ 1 回以上捕捉されること、(3) 捕捉された呼び出しの `ContainerName` がいずれも `Work` であること、(4) 逐語本体に埋め込まれた phantom 識別子がいずれも参照行として現れないこと、の 4 点を同時に固定する。対象: `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`。Closes #264。 - **派生クラスにネストした `new interface`(ベースクラスの同名 nested interface を隠蔽する形)の抽出を専用回帰テストで固定化 (#376)** — Issue #376 は、派生クラスにネストした `public new interface INested { ... }` が無言で落ちる、`SymbolExtractor` の interface 正規表現の修飾子リストが `(?:partial|unsafe)` どまりで `new` が含まれていないのが根本原因、という報告だった。#355 の自由順修飾子書き換えでこのリストは既に `(?:partial|unsafe|file|new)` に拡張されており、現在のバイナリでは `Base` と `Derived` の双方で `interface INested` が 2 件抽出されるため、#376 の再現手順ではもう症状を再現できない。本変更はその挙動を専用回帰テスト `Extract_CSharp_NewNestedInterface_MemberHiding` として固定する。Issue 記載の fixture をそのまま流し、両 nested interface(`ContainerName == "Base"` と `ContainerName == "Derived"`)が抽出されることをアサートするので、今後の修飾子リスト編集で #376 のケースが失敗テスト無しに静かに再回帰しないようになる。対象: `tests/CodeIndex.Tests/SymbolExtractorTests.cs`。Closes #376。 - **C# 8 の struct 上 `readonly` インスタンスインデクサが `Item` シンボルとして抽出されることを回帰テストで固定化 (#352)** — `SymbolExtractor` は以前、`public readonly int this[int i] => _arr[i];` や `public readonly string this[string key] { get => key; }` を silent drop していたため、`readonly` インデクサを多用する slice / span / イミュータブル wrapper struct の表面積を `definition` / `symbols` / `outline` / `inspect` が過小にしていた。#355 の書き換えに伴って C# インデクサ行の自由順 modifier スロットが `readonly` に加えて `unsafe` / `extern` / `ref (readonly)` も受理するようになっているが、今回はその挙動を専用回帰テスト `Extract_CSharp_DetectsReadonlyIndexers` で固定する。式本体、ブロック本体、ジェネリック戻り値 (`public readonly T this[int i] { get => _items[i]; }`) の三形態と、非 readonly / readonly メソッドのベースラインまで含めてカバーし、今後の regex リファクタで #352 のケースが再回帰したときに失敗テストを伴わずに壊れないようにする。対象: `tests/CodeIndex.Tests/SymbolExtractorTests.cs`。 diff --git a/CLAUDE.md b/CLAUDE.md index f7a0e70b0f..c9580f72bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ tests/CodeIndex.Tests/ - **Stale file purge** — Before indexing, removes DB entries for files no longer on disk (branch switch support). - **Batch commits** — 500 records per transaction for write performance. Supports nesting via SAVEPOINT. - **FTS5** — `fts_chunks` virtual table mirrors `chunks.content` for full-text search. Sync via database triggers (AFTER INSERT/DELETE/UPDATE on chunks). FTS5 optimize runs after indexing. -- **Literal-safe search by default** — Search queries are quoted token-by-token to avoid FTS syntax errors by default. Raw FTS5 syntax is opt-in via `--fts` or MCP `rawQuery`. +- **Literal-safe search by default** — Search queries are quoted token-by-token to avoid FTS syntax errors by default. Raw FTS5 syntax is opt-in via `--fts` or MCP `rawQuery`. Tokens that contain CJK script are emitted as FTS5 prefix phrases (`"計算"*`) instead of bare phrases, so `search 計算` can still find `計算する` even though the default `unicode61` tokenizer treats the whole CJK run as a single token. The CJK set covers Hiragana / Katakana / phonetic extensions / Kana Supplement + Kana Extended-A + Small Kana Extension (`U+1B000..U+1B16F`) / Kana Extended-B (`U+1AFF0..U+1AFFF`), Bopomofo + Bopomofo Extended (Chinese phonetic / zhuyin, `U+3100..U+312F` and `U+31A0..U+31BF`), CJK Unified Ideographs + Extensions A–I + Compatibility, Han-script codepoints outside the Unified Ideographs blocks (ideographic iteration mark `々` `U+3005`, closing mark `〆` `U+3006`, number zero `〇` `U+3007`, Hangzhou numerals `U+3021..U+3029`, vertical kana repeat marks `U+3031..U+3035`, Hangzhou 10/20/30 + vertical iteration marks `U+3038..U+303B`), the historical East Asian scripts that Unicode groups with or adjacent to the CJK blocks and that `unicode61` keeps as word characters, enumerated as individual Unicode-block envelopes so each block has its own predicate branch and regression test — Yi Syllables (`U+A000..U+A48F`, Nuosu syllabary), Tangut (`U+17000..U+187FF`), Tangut Components (`U+18800..U+18AFF`), Khitan Small Script (`U+18B00..U+18CFF`), Tangut Supplement (`U+18D00..U+18D8F`), and Nüshu (`U+1B170..U+1B2FF`, non-BMP women's script from Jiangyong County, Hunan); the individual iteration / annotation codepoints in the Ideographic Symbols and Punctuation block that `unicode61` keeps as word characters — Tangut Iteration Mark `U+16FE0` (`Lm`), Nüshu Iteration Mark `U+16FE1` (`Lm`), Old Chinese Iteration Mark `U+16FE3` (`Lm`), Khitan Small Script Filler `U+16FE4` (`Mn`), and Vietnamese Chu Nom Alternate Reading Marks CA / NHAY `U+16FF0..U+16FF1` (`Mc`) — with `U+16FE2` (Old Chinese Hook Mark, `Po`) and Yi Radicals (`U+A490..U+A4CF`, `So`) intentionally excluded because `unicode61` drops them during tokenization. `OtherNotAssigned` (`Cn`) is intentionally NOT excluded at the top level. .NET's Unicode tables lag the Unicode Consortium's releases, so newly assigned codepoints (e.g. Extension I `U+2EBF0..U+2EE5F`, Unicode 15.1, assigned 2023) still report `Cn` on .NET 8 — rejecting `Cn` would silently regress real CJK codepoints just because the runtime has not caught up. `unicode61` also ships its own (different and older) Unicode tables, and empirical verification through the locally built binary on .NET 8 confirms that codepoints .NET reports as `Cn` inside the block envelopes above (e.g. Tangut's `U+187F8..U+187FF`, Khitan's `U+18CD6..U+18CFF`, Tangut Supplement's `U+18D09..U+18D8F`) are still kept as word characters by `unicode61` during indexing, so promoting them through the block envelopes produces correct behavior: `search ` matches a file genuinely containing `abc` and returns nothing otherwise. Finally: Hangul syllables + Jamo, and halfwidth forms from halfwidth Katakana through halfwidth Hangul (`U+FF65..U+FFDC`). The prefix promotion is unconditional for CJK tokens, so `search 計算する` also returns chunks containing `計算する追加`; callers who need strict equality on CJK substrings should use `--exact` / `instr`. Non-CJK tokens — including Latin-diacritic (`naïve`), Greek, Cyrillic, and emoji-mixed text — skip the CJK prefix path: `unicode61` tokenizes Latin-diacritic normally (so prefix promotion would silently widen `naïve` to `naïvety`), and `unicode61` drops symbol codepoints entirely, meaning `"foo🎉"*` would be parsed as `"foo"*` and leak into `foobar`. Emoji-mixed tokens also cannot be distinguished from their plain ASCII counterpart at the FTS layer because `unicode61` strips the emoji on both the index side and the query side — `foo🎉` is both indexed and queried as the token `foo`, so `search foo🎉` also returns `def foo():`; strict emoji-bearing equality requires `--exact`. The halfwidth range intentionally stops at `U+FFDC`: `U+FFDD..U+FFDF` are unassigned and `U+FFE0..U+FFEE` are fullwidth currency / arrow symbols that `unicode61` drops entirely. CJK Radicals Supplement (`U+2E80..U+2EFF`) and Kangxi Radicals (`U+2F00..U+2FDF`) are intentionally excluded from the CJK set because every codepoint in those blocks is Unicode `OtherSymbol` and is dropped by `unicode61` during tokenization. Emoji-only substring search (`search 🎉`) is also still 0-result for the same reason and needs a tokenizer-level schema change (tracked via issue #198 follow-ups). - **Path-aware narrowing and ranking** — `search`, `definition`, `references`, `callers`, `callees`, `symbols`, and `files` share repeatable `--path` (multiple values are OR'd together), repeatable `--exclude-path`, and `--exclude-tests`. Query ordering prefers source files over tests/docs, and `search` boosts exact symbol-name and path matches. - **Compact search snippets for AI** — `search --json` and MCP `search` return match-centered snippets with snippet ranges, match lines, highlights, context counts, and a `truncated_line_count` summary instead of whole chunks. `--snippet-lines` lets clients cap payload size up front, and `--max-line-width` (CLI) / `maxLineWidth` (MCP) clamps individual snippet lines around the first match token via the shared `LineWidthFormatter` contract used by `find` / `references` / `excerpt` / `inspect` (default: 512, max: 4096) so a single match inside a minified / transpiled / generated file no longer returns hundreds of KB per hit. Clamped lines surface `...(+N)...` markers in the snippet and carry `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. - **Repo map for first-pass orientation** — `map` summarizes languages, modules, top files, file hot spots, and likely entrypoints so AI clients can form an initial navigation plan before issuing deeper queries. Entrypoint inference falls back to known top-level entry files when symbol extraction does not yield an explicit `Main`-style symbol. @@ -359,7 +359,7 @@ tests/CodeIndex.Tests/ - **古いファイルのパージ** — インデックス前にディスク上に存在しないファイルをDBから削除(ブランチ切り替え対応)。 - **バッチコミット** — 書き込み性能のため1トランザクション500レコード。SAVEPOINTによるネスト対応。 - **FTS5** — `fts_chunks`仮想テーブルが`chunks.content`をミラーして全文検索を提供。データベーストリガー(chunksのAFTER INSERT/DELETE/UPDATE)で同期。インデックス後にFTS5 optimizeを実行。 -- **デフォルトはリテラル安全検索** — 検索クエリは既定ではトークンごとに引用し、FTS構文エラーを避ける。生のFTS5構文は `--fts` またはMCPの `rawQuery` で明示 opt-in。 +- **デフォルトはリテラル安全検索** — 検索クエリは既定ではトークンごとに引用し、FTS構文エラーを避ける。生のFTS5構文は `--fts` またはMCPの `rawQuery` で明示 opt-in。CJK スクリプトを含むトークンは、素のフレーズではなく FTS5 prefix phrase (`"計算"*`) として発行する。unicode61 既定トークナイザは CJK 連続を単一トークンとして扱うため、この prefix 昇格により `search 計算` が `計算する` を含むチャンクにマッチする。CJK セットの範囲は、ひらがな・カタカナ・音声拡張・Kana Supplement + Kana Extended-A + Small Kana Extension (`U+1B000..U+1B16F`)・Kana Extended-B (`U+1AFF0..U+1AFFF`)、注音符号(ボポモフォ)・拡張注音符号(中国語発音、`U+3100..U+312F` と `U+31A0..U+31BF`)、CJK 統合漢字および拡張 A–I・互換、CJK 統合漢字の範囲外にある Han script コードポイント(漢字反復記号 `々` `U+3005`、閉じ記号 `〆` `U+3006`、漢数字ゼロ `〇` `U+3007`、Hangzhou 数字 `U+3021..U+3029`、縦書き仮名反復記号 `U+3031..U+3035`、Hangzhou 10/20/30 と縦書き反復記号 `U+3038..U+303B`)、Unicode 上で CJK ブロックに隣接配置され `unicode61` が単語文字として扱う東アジアの歴史的文字をブロック単位に列挙したもの(各ブロックごとに predicate 分岐と回帰テストを分けてある)— 彝文字音節(Yi Syllables、`U+A000..U+A48F`、中国南西部ノス族の音節文字)、西夏文字(Tangut、`U+17000..U+187FF`)、西夏文字部品(Tangut Components、`U+18800..U+18AFF`)、契丹小字(Khitan Small Script、`U+18B00..U+18CFF`)、西夏文字補助(Tangut Supplement、`U+18D00..U+18D8F`)、女書(Nüshu、`U+1B170..U+1B2FF`、中国湖南省江永県の非 BMP 女性専用音節文字)。加えて Ideographic Symbols and Punctuation ブロック中の個別反復 / 注釈記号 — Tangut 反復記号 `U+16FE0`(`Lm`)、Nüshu 反復記号 `U+16FE1`(`Lm`)、Old Chinese 反復記号 `U+16FE3`(`Lm`)、Khitan Small Script filler `U+16FE4`(`Mn`)、ベトナム語 Chu Nom 読み記号 CA / NHAY `U+16FF0..U+16FF1`(`Mc`)。`U+16FE2`(Old Chinese Hook Mark、`Po`)と彝文字部首(Yi Radicals、`U+A490..U+A4CF`、`So`)は `unicode61` がトークナイザ段階で drop するため意図的に除外している。`OtherNotAssigned`(`Cn`)は上流の除外に意図的に含めていない。.NET の Unicode テーブルは Unicode Consortium のリリースに遅れるため、Extension I の `U+2EBF0..U+2EE5F`(Unicode 15.1、2023 年割当)のような新しく割り当てられたコードポイントでも .NET 8 では依然として `Cn` と報告される。`Cn` を除外すると、ランタイム側のテーブル更新遅延だけを理由に実在の CJK コードポイントが静かに regressions する。`unicode61` も .NET とは別の(そしてより古い)Unicode テーブルを持っており、ローカルビルドしたバイナリを .NET 8 で動かして検証した結果、上記ブロック末尾の予約領域(例: Tangut の `U+187F8..U+187FF`、Khitan の `U+18CD6..U+18CFF`、Tangut Supplement の `U+18D09..U+18D8F`)のような `Cn` 報告のコードポイントも `unicode61` は indexing 側で単語文字として保持する。よってこれらをブロックベース範囲で prefix 昇格しても正しい挙動になる — ファイル中に `<コードポイント>abc` が実際に存在すれば `search <コードポイント>` はヒットし、存在しなければ何も返さない。ハングル音節・Jamo、半角カナから半角ハングルまでの半角形 (`U+FF65..U+FFDC`)。prefix 昇格は CJK トークンに対して無条件で行われるので `search 計算する` は `計算する追加` を含むチャンクも返す。CJK 部分文字列の厳密一致が必要な場合は `--exact` / `instr` 経路を使う。CJK 以外の非 ASCII トークン(`naïve` 等の Latin-diacritic、Greek、Cyrillic、絵文字混在)は CJK prefix 経路を通さない。unicode61 は Latin-diacritic を通常どおりトークン化するため prefix 化すると `naïve` が `naïvety` に静かに広がってしまい、`foo🎉` を prefix 化すると FTS5 側で `"foo"*` として解釈されて `foobar` など無関係な ASCII 近傍にまで拡張されてしまうため。さらに emoji 混在トークンは、`unicode61` が indexing 側でもクエリ側でも emoji を削ぐため FTS 層では素の ASCII トークンと区別できず(`foo🎉` は両側で `foo` にトークン化される)、`search foo🎉` は `def foo():` を含むファイルもマッチする。emoji 付きの厳密一致が必要な場合は `--exact` を使う。半角形の上限を `U+FFDC` にしているのは、`U+FFDD..U+FFDF` が未割当、`U+FFE0..U+FFEE` が全角通貨/矢印記号で `unicode61` のトークナイザ段階で drop されるため。CJK Radicals Supplement(`U+2E80..U+2EFF`)と Kangxi Radicals(`U+2F00..U+2FDF`)は CJK セットから意図的に除外している。両ブロックの全コードポイントは Unicode カテゴリ `OtherSymbol` で `unicode61` のトークナイザ段階で drop されるため、prefix fallback を有効化しても救えない。emoji 単独の部分一致 (`search 🎉`) も同じ理由でトークナイザ段階で 0 件になり、スキーマ側のトークナイザ設定変更(issue #198 の follow-up)が必要。 - **パス考慮の絞り込みとランキング** — `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は繰り返し指定できる `--path`(複数値は OR で結合)、繰り返し指定できる `--exclude-path`、`--exclude-tests` を共有する。クエリ結果は tests や docs より source を優先し、`search` はシンボル名やパスの exact match を追加ブーストする。 - **AI向けの軽量検索スニペット** — `search --json` と MCP の `search` は、チャンク全文ではなく snippet range、match line、highlight、context count、`truncated_line_count` サマリを含む一致中心スニペットを返す。`--snippet-lines` でペイロード量を先に制限でき、`--max-line-width`(CLI)/ `maxLineWidth`(MCP)は `find` / `references` / `excerpt` / `inspect` と同じ共有 `LineWidthFormatter` 契約(デフォルト: 512、最大: 4096)で各スニペット行を最初のマッチトークン周辺にクランプするため、minified / transpiled / 生成ファイル内の 1 ヒットで数百 KB を返さなくなる。クランプされた行はスニペットに `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できる。 - **初動向けの repo map** — `map` は、言語、モジュール、主要ファイル、ホットスポット、推定エントリポイントを要約し、AIクライアントが深い検索前に移動計画を立てやすくする。シンボル抽出だけで入口が取れない場合も、既知のトップレベル実行ファイルへフォールバックして候補を補う。 diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index fb2232f97a..ac8657e98d 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text; using Microsoft.Data.Sqlite; namespace CodeIndex.Database; @@ -22,7 +24,248 @@ private static string SanitizeFtsQuery(string query) var tokens = query.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) return "\"\""; - return string.Join(" ", tokens.Select(t => "\"" + t.Replace("\"", "\"\"") + "\"")); + return string.Join(" ", tokens.Select(FormatFtsToken)); + } + + /// + /// Build a single FTS5 phrase token. Tokens that contain CJK script (Han, Hiragana, + /// Katakana, Hangul, and their fullwidth/halfwidth variants) get an appended '*' + /// prefix-match operator because FTS5's default unicode61 tokenizer treats a run + /// of adjacent CJK codepoints as a single token, so a bare '計算' query would never + /// match content containing '計算する'. The CJK prefix promotion is unconditional, + /// so a full-token CJK query also widens to longer tokens (e.g. '計算する' also + /// matches '計算する追加'); callers who need strict equality on CJK substrings must + /// route through the `exact` / `instr` path. + /// FTS5フレーズトークンを構築。FTS5既定のunicode61トークナイザはCJK連続を単一トークンとして扱うため、 + /// CJK文字を含むトークンには prefix match の '*' を付け、'計算' で '計算する' にマッチさせる。 + /// prefix 昇格は CJK トークンに対して無条件なので、完全トークンのクエリでも長いトークンへ広がる + /// ('計算する' は '計算する追加' もマッチ)。CJK 部分文字列の厳密一致が必要な呼び出し側は + /// `exact` / `instr` 経路を使う。 + /// + /// Non-CJK tokens (Latin-diacritic like 'café', Greek, Cyrillic, emoji-mixed, etc.) + /// skip the CJK prefix path. This prevents prefix over-widening (e.g. 'foo🎉' → + /// 'foo*' matching 'foobar'), but it does NOT make those tokens "exact-phrase" at + /// the FTS layer: unicode61 tokenizes Latin-diacritic normally and drops symbol + /// codepoints entirely, so 'foo🎉' is indexed and queried as the token 'foo' and + /// therefore cannot be distinguished from a bare `def foo():` in FTS. Callers who + /// need strict equality with emoji must route through the `exact` / `instr` path. + /// CJK 以外のトークン(Latin-diacritic、Greek、Cyrillic、絵文字混在等)は CJK prefix 経路を + /// 通さない。これは prefix の過度な拡張('foo🎉' → 'foo*' で 'foobar' まで拾う)を防ぐが、 + /// FTS 層で「完全一致」を保証するものではない。unicode61 は Latin-diacritic を通常どおり + /// トークン化し、symbol コードポイントは完全に drop するため、'foo🎉' は両側で 'foo' と + /// トークン化され素の `def foo():` と区別できない。emoji の厳密一致が必要な場合は + /// `exact` / `instr` 経路を使う。 + /// + private static string FormatFtsToken(string token) + { + var quoted = "\"" + token.Replace("\"", "\"\"") + "\""; + // '"phrase"*' (no space) is FTS5 prefix-phrase syntax. '"phrase" *' (with space) + // means "phrase followed by any token", which is not what we want. + // '"phrase"*'(スペースなし)がFTS5のprefix phrase構文。スペース有りは別意味なので付けない。 + return ContainsCjk(token) ? quoted + "*" : quoted; + } + + private static bool ContainsCjk(string token) + { + foreach (var rune in token.EnumerateRunes()) + { + if (IsCjkScript(rune)) + return true; + } + return false; + } + + private static bool IsCjkScript(Rune rune) + { + // Exclude symbol categories up front so emoji / pictographs / currency marks + // never trigger CJK prefix fallback even if they live inside CJK-adjacent blocks. + // `OtherNotAssigned` (Cn) is deliberately NOT excluded. The simple reason is that + // .NET's Unicode tables lag the Unicode Consortium's releases — codepoints newly + // assigned by Unicode (e.g. Extension I at U+2EBF0..U+2EE5F, assigned in Unicode + // 15.1) still report as Cn on .NET 8 — so excluding Cn here would silently regress + // real CJK codepoints simply because the runtime has not caught up. The subtler + // reason is that SQLite's bundled `unicode61` tokenizer carries its own (different + // and older) Unicode tables: empirical verification through the locally built + // binary on .NET 8 shows that codepoints which .NET reports as Cn inside the + // block-envelope ranges below (e.g. Tangut's reserved U+187F8..U+187FF, Khitan's + // reserved U+18CD6..U+18CFF, Tangut Supplement's reserved U+18D09..U+18D8F) are + // still kept as word characters by unicode61 during indexing. Allowing them to + // prefix-promote is therefore not only safe but necessary: it preserves the same + // "`search ` must match a chunk containing `abc`" invariant + // that the whole #198 fix is built on. Truly unreachable codepoints (ones that + // neither .NET nor unicode61 treat as word characters) cannot produce a match even + // if the predicate returns true, because FTS5's query tokenizer drops them on its + // own side. + // シンボル系カテゴリは先に除外。emoji等がCJK隣接ブロックにあってもprefix fallbackを起こさせない。 + // `OtherNotAssigned` (Cn) は意図的に除外しない。単純な理由: .NET の Unicode テーブルは + // Unicode Consortium のリリースに遅れるため、Unicode で新たに割り当てられた + // コードポイント(例: Unicode 15.1 で追加された Extension I の U+2EBF0..U+2EE5F)も + // .NET 8 では依然として Cn と報告される。ここで Cn を除外すると、ランタイム側のテーブル + // 更新遅延だけを理由に実在の CJK コードポイントが静かに regressions する。踏み込んだ理由: + // SQLite 同梱の `unicode61` トークナイザは .NET とは別の(そしてより古い)Unicode + // テーブルを持っており、ローカルビルドしたバイナリを .NET 8 で動かして検証した結果、 + // .NET が Cn と報告する「ブロック末尾の予約領域」のコードポイント (例: Tangut の + // U+187F8..U+187FF、Khitan の U+18CD6..U+18CFF、Tangut Supplement の + // U+18D09..U+18D8F) も unicode61 は indexing 側で単語文字として保持する。よってこれらを + // prefix 昇格の対象に含めるのは安全かつ必要で、#198 の根幹不変条件「`search ` + // は `abc` を含むチャンクに必ずヒットする」を壊さないための前提になっている。 + // 本当に到達不能なコードポイント(.NET も unicode61 も単語文字として扱わないもの)は、 + // 述語が true を返しても FTS5 側のクエリトークナイザが落とすので一致は発生しない。 + var category = Rune.GetUnicodeCategory(rune); + if (category is UnicodeCategory.OtherSymbol + or UnicodeCategory.MathSymbol + or UnicodeCategory.CurrencySymbol + or UnicodeCategory.ModifierSymbol) + return false; + + var value = rune.Value; + // Hiragana / Katakana (including Phonetic Extensions and Katakana Phonetic Extensions), + // Kana Supplement + Kana Extended-A + Small Kana Extension (U+1B000..U+1B16F), and + // Kana Extended-B (U+1AFF0..U+1AFFF, Unicode 15.0). + // ひらがな・カタカナ(音声拡張を含む)、Kana Supplement / Extended-A / Small Kana Extension、 + // および Kana Extended-B。 + if (value >= 0x3040 && value <= 0x30FF) return true; + if (value >= 0x31F0 && value <= 0x31FF) return true; + if (value >= 0x1AFF0 && value <= 0x1AFFF) return true; + if (value >= 0x1B000 && value <= 0x1B16F) return true; + + // Bopomofo (U+3100..U+312F) and Bopomofo Extended (U+31A0..U+31BF). These are the + // Mandarin Chinese phonetic system ("zhuyin"). Bopomofo letters are Unicode category + // Lo (Other Letter), so unicode61 keeps them as regular word characters, which means + // the same "short query vs longer token" failure mode as the original #198 repro + // applies to Chinese phonetic text like 'ㄅabc' without explicit CJK prefix promotion. + // 注音符号(ボポモフォ)と拡張注音符号。中国語の発音記号で Unicode カテゴリは Lo。 + // unicode61 は通常の単語文字として扱うため、'ㄅabc' のような中国語発音テキストでも + // #198 の 0 件症状を起こさないよう CJK prefix 昇格の対象に含める。 + if (value >= 0x3100 && value <= 0x312F) return true; + if (value >= 0x31A0 && value <= 0x31BF) return true; + + // Han-script codepoints that live outside the CJK Unified Ideographs / Extensions + // blocks: ideographic iteration/closing marks (々 U+3005, 〆 U+3006), the ideographic + // number zero (〇 U+3007), Hangzhou numerals (U+3021..U+3029), vertical kana repeat + // marks (U+3031..U+3035), and the Hangzhou 10/20/30 + vertical iteration mark block + // (U+3038..U+303B). U+3031..U+3035 are Unicode category Lm (Letter Modifier), while + // the others are Lo / Nl — all are kept as word characters by unicode61, so they need + // CJK prefix promotion to match against longer indexed tokens like '々abc', '〱abc', or + // '〇abc'. The narrow explicit list avoids the whole CJK Symbols and Punctuation block + // (U+3000..U+303F), which includes ideographic space / brackets / dots that unicode61 + // either drops or already tokenizes on. + // CJK Unified Ideographs 範囲外の Han script コードポイント: 々(U+3005)、〆(U+3006)、 + // 〇(U+3007)、Hangzhou 数字(U+3021..U+3029)、縦書き仮名反復記号(U+3031..U+3035)、 + // Hangzhou 10/20/30 と縦書き反復記号(U+3038..U+303B)。U+3031..U+3035 は Unicode カテゴリ Lm、 + // その他は Lo / Nl。いずれも unicode61 で通常の単語文字として扱われるため、'々abc'、 + // '〱abc'、'〇abc' のような長いトークンへ CJK prefix 昇格を届かせる必要がある。 + // CJK Symbols and Punctuation ブロック全体(U+3000..U+303F)を拾わないよう、個別列挙にしている。 + if (value == 0x3005) return true; + if (value == 0x3006) return true; + if (value == 0x3007) return true; + if (value >= 0x3021 && value <= 0x3029) return true; + if (value >= 0x3031 && value <= 0x3035) return true; + if (value >= 0x3038 && value <= 0x303B) return true; + + // CJK Unified Ideographs + Extensions A-I, Compatibility Ideographs. + // CJK Radicals Supplement (U+2E80..U+2EFF) and Kangxi Radicals (U+2F00..U+2FDF) + // are intentionally excluded: every codepoint in those blocks is Unicode + // category OtherSymbol (So) and gets dropped by unicode61 during tokenization, + // so prefix fallback cannot help anyway. + // CJK統合漢字および拡張 A-I、互換漢字。CJK Radicals Supplement と Kangxi Radicals は + // Unicodeカテゴリが OtherSymbol (So) で unicode61 が drop するため、prefix fallback を + // かけても意味がなく、意図的に除外する。 + if (value >= 0x4E00 && value <= 0x9FFF) return true; + if (value >= 0x3400 && value <= 0x4DBF) return true; + if (value >= 0x20000 && value <= 0x2A6DF) return true; + if (value >= 0x2A700 && value <= 0x2EBEF) return true; + if (value >= 0x2EBF0 && value <= 0x2EE5F) return true; // Extension I (Unicode 15.1) + if (value >= 0x30000 && value <= 0x3134F) return true; // Extension G + if (value >= 0x31350 && value <= 0x323AF) return true; // Extension H (Unicode 15.0) + if (value >= 0xF900 && value <= 0xFAFF) return true; + if (value >= 0x2F800 && value <= 0x2FA1F) return true; + + // Historical East Asian scripts that unicode61 keeps as word characters and which + // Unicode groups with / adjacent to CJK. Each Unicode block is listed as its own + // branch so (a) regression tests can exercise one branch at a time and (b) future + // narrowing or widening of any single block does not drag the others. Block-envelope + // ranges cover each block's full Unicode-block bounds including the small reserved + // holes at each block's tail (e.g. Tangut's U+187F8..U+187FF, Khitan Small Script's + // U+18CD6..U+18CFF, Tangut Supplement's U+18D09..U+18D8F, which .NET 8 reports as + // Cn on today's runtime tables). Empirically — verified through the locally built + // binary on .NET 8 — unicode61 still keeps those codepoints as word characters + // during indexing, so content containing `abc` is indexed as a + // single token and needs prefix promotion to satisfy the same #198 invariant. + // Prefix-promoting these holes therefore produces correct behavior: if the + // codepoint appears in indexed content, the search finds it; if it does not, FTS5 + // simply returns no rows. + // * Yi Syllables (U+A000..U+A48F, Lo — Nuosu syllabary) + // * Tangut (U+17000..U+187FF, Lo — Western Xia logographs, non-BMP) + // * Tangut Components (U+18800..U+18AFF, Lo — non-BMP) + // * Khitan Small Script (U+18B00..U+18CFF, Lo — non-BMP) + // * Tangut Supplement (U+18D00..U+18D8F, Lo — non-BMP) + // * Nüshu (U+1B170..U+1B2FF, Lo — Jiangyong women's script, non-BMP) + // Also add the individual iteration / annotation codepoints in the Ideographic + // Symbols and Punctuation block (U+16FE0..U+16FF1) that annotate Han-based + // historical text. On the current runtime these are category Lm (U+16FE0 Tangut + // iteration mark, U+16FE1 Nüshu iteration mark, U+16FE3 Old Chinese iteration + // mark), Mn (U+16FE4 Khitan Small Script filler), and Mc (U+16FF0 / U+16FF1 + // Vietnamese alternate reading marks CA / NHAY). All are kept as word characters + // by unicode61 and reproduce the #198 zero-hit shape without explicit CJK prefix + // promotion. U+16FE2 (Old Chinese hook mark) is intentionally excluded because + // it is Unicode category Po and unicode61 drops it during tokenization. Yi + // Radicals (U+A490..U+A4CF) are intentionally excluded because they are Unicode + // category So and are also dropped by unicode61 during tokenization. + // 東アジアの歴史的文字で unicode61 が単語文字として扱い、Unicode 上も CJK 隣接に + // 配置されているブロック。将来いずれかを絞ったり広げたりしても他ブロックが巻き添えに + // ならないよう、Unicode ブロック単位で個別分岐にする。ブロックベース範囲は各ブロックの + // Unicode ブロック境界全域を覆うので、末尾の小さな予約領域(例: Tangut の + // U+187F8..U+187FF、Khitan の U+18CD6..U+18CFF、Tangut Supplement の U+18D09..U+18D8F、 + // いずれも現行の .NET 8 ランタイムでは Cn)も含まれる。ローカルビルドしたバイナリを + // .NET 8 で動かして実証した結果、これらの予約領域コードポイントも unicode61 は + // indexing 側で単語文字として保持する。したがって `<予約領域>abc` を含むコンテンツは + // 単一トークンとしてインデックスされ、#198 と同じ不変条件を満たすには prefix 昇格が必要。 + // 予約領域を prefix 昇格してもコンテンツ中にそのコードポイントが現れれば一致するだけで、 + // 存在しなければ FTS5 は単に 0 行を返す — どちらも期待される挙動で誤マッチにはならない。 + // * Yi Syllables (U+A000..U+A48F, Lo — 中国南西部のノス族音節文字) + // * Tangut (U+17000..U+187FF, Lo — 西夏の表意文字、非 BMP) + // * Tangut Components (U+18800..U+18AFF, Lo — 非 BMP) + // * Khitan Small Script (U+18B00..U+18CFF, Lo — 非 BMP) + // * Tangut Supplement (U+18D00..U+18D8F, Lo — 非 BMP) + // * Nüshu (U+1B170..U+1B2FF, Lo — 湖南省江永県の女性専用音節文字、非 BMP) + // また Ideographic Symbols and Punctuation ブロック (U+16FE0..U+16FF1) の反復 / + // 注釈記号も個別に含める。現行ランタイムではカテゴリ Lm (U+16FE0 Tangut 反復記号、 + // U+16FE1 Nüshu 反復記号、U+16FE3 Old Chinese 反復記号)、Mn (U+16FE4 Khitan Small + // Script filler)、Mc (U+16FF0 / U+16FF1 ベトナム語 Chu Nom 読み記号 CA / NHAY)。 + // いずれも unicode61 は単語文字として保持するため、CJK prefix 昇格を入れないと + // #198 と同じ 0 件症状を再現する。U+16FE2 (Old Chinese hook mark) は Unicode カテゴリ + // Po で unicode61 が drop するため意図的に除外する。Yi Radicals (U+A490..U+A4CF) は + // Unicode カテゴリ So でこれも unicode61 が drop するため意図的に除外する。 + if (value == 0x16FE0) return true; + if (value == 0x16FE1) return true; + if (value == 0x16FE3) return true; + if (value == 0x16FE4) return true; + if (value >= 0x16FF0 && value <= 0x16FF1) return true; + if (value >= 0xA000 && value <= 0xA48F) return true; + if (value >= 0x17000 && value <= 0x187FF) return true; + if (value >= 0x18800 && value <= 0x18AFF) return true; + if (value >= 0x18B00 && value <= 0x18CFF) return true; + if (value >= 0x18D00 && value <= 0x18D8F) return true; + if (value >= 0x1B170 && value <= 0x1B2FF) return true; + + // Hangul Syllables, Jamo, Jamo Extended-A/B, Compatibility Jamo + // ハングル音節およびJamo + if (value >= 0xAC00 && value <= 0xD7AF) return true; + if (value >= 0x1100 && value <= 0x11FF) return true; + if (value >= 0x3130 && value <= 0x318F) return true; + if (value >= 0xA960 && value <= 0xA97F) return true; + if (value >= 0xD7B0 && value <= 0xD7FF) return true; + + // Halfwidth and Fullwidth Forms: halfwidth Katakana (U+FF65..U+FF9F) plus + // halfwidth Hangul Letters (U+FFA0..U+FFDC). The range intentionally stops + // at U+FFDC: U+FFE0..U+FFEE are fullwidth currency / arrow symbols that + // unicode61 drops anyway, and U+FFDD..U+FFDF are unassigned. + // Halfwidth/Fullwidth ブロックの半角カナ (U+FF65..U+FF9F) と半角ハングル + // (U+FFA0..U+FFDC)。U+FFDD 以降は未割当や unicode61 が drop する記号なので含めない。 + if (value >= 0xFF65 && value <= 0xFFDC) return true; + + return false; } /// diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index e77d28438a..05653b9f92 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -20,7 +20,7 @@ private JsonNode HandleToolsList(JsonNode? id) { CreateToolDefinition( "search", - "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。", + "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. CJK tokens in the literal-safe path are auto-upgraded to FTS5 prefix phrases so `search 計算` matches content containing `計算する` without needing `rawQuery`. The CJK set covers Hiragana / Katakana / phonetic extensions / Kana Supplement / Kana Extended-A / Small Kana Extension / Kana Extended-B, Bopomofo + Bopomofo Extended (Chinese zhuyin), CJK Unified Ideographs + Extensions A–I + Compatibility, Han-script codepoints outside the Unified Ideographs blocks (々 〆 〇, Hangzhou numerals, vertical kana repeat marks U+3031..U+3035, vertical iteration marks U+3038..U+303B), the historical East Asian scripts that Unicode places in or adjacent to the CJK blocks and that unicode61 keeps as word characters, listed as individual block envelopes — Yi Syllables (U+A000..U+A48F), Tangut (U+17000..U+187FF), Tangut Components (U+18800..U+18AFF), Khitan Small Script (U+18B00..U+18CFF), Tangut Supplement (U+18D00..U+18D8F), and Nüshu (U+1B170..U+1B2FF) — plus the iteration / annotation codepoints in the Ideographic Symbols and Punctuation block that unicode61 keeps as word characters (U+16FE0 / U+16FE1 / U+16FE3 Lm, U+16FE4 Mn, U+16FF0..U+16FF1 Mc), with U+16FE2 (Po) excluded because unicode61 drops it. `OtherNotAssigned` (Cn) is deliberately NOT excluded at the top level because .NET's Unicode tables lag the Unicode Consortium (e.g. Extension I U+2EBF0..U+2EE5F is Cn on .NET 8 despite being assigned in Unicode 15.1), and unicode61's own tokenizer tables also keep reserved codepoints inside the block envelopes as word characters, so prefix-promoting them produces correct behavior (match only when the content actually contains the codepoint). Hangul syllables + Jamo, and halfwidth forms through halfwidth Hangul (U+FF65..U+FFDC). The CJK prefix promotion is unconditional, so `search 計算する` also returns chunks containing `計算する追加` — use the `exact` flag for strict equality on CJK substrings. Non-CJK tokens — including Latin-diacritic (`naïve`), Greek, Cyrillic, and emoji-mixed text — skip the CJK prefix path to avoid over-widening. Emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer (unicode61 drops the emoji on both index and query side — `foo🎉` is FTS-equivalent to `foo`), and pure emoji substring search is 0-result for the same reason; use `exact` when emoji identity matters. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。literal-safe 経路では CJK トークンのみ自動で FTS5 prefix phrase に昇格するため、`search 計算` は `rawQuery` なしでも `計算する` を含むコードにマッチする。CJK セットは、ひらがな・カタカナ・音声拡張・Kana Supplement・Kana Extended-A・Small Kana Extension・Kana Extended-B、注音符号(ボポモフォ)と拡張注音符号(中国語発音)、CJK 統合漢字と拡張 A–I・互換、CJK 統合漢字範囲外の Han script コードポイント(々・〆・〇、Hangzhou 数字、縦書き仮名反復記号 U+3031..U+3035、縦書き反復記号 U+3038..U+303B 等)、Unicode 上で CJK ブロックに隣接配置され unicode61 が単語文字として扱う東アジアの歴史的文字をブロック単位で列挙 — 彝文字音節(Yi Syllables、U+A000..U+A48F)、西夏文字(Tangut、U+17000..U+187FF)、西夏文字部品(Tangut Components、U+18800..U+18AFF)、契丹小字(Khitan Small Script、U+18B00..U+18CFF)、西夏文字補助(Tangut Supplement、U+18D00..U+18D8F)、女書(Nüshu、U+1B170..U+1B2FF) — 加えて Ideographic Symbols and Punctuation ブロックの反復 / 注釈記号のうち unicode61 が単語文字として扱うもの(U+16FE0 / U+16FE1 / U+16FE3 Lm、U+16FE4 Mn、U+16FF0..U+16FF1 Mc)。U+16FE2(Po)は unicode61 が drop するため除外。`OtherNotAssigned`(Cn)は上流の除外に意図的に含めない — .NET の Unicode テーブルは Unicode Consortium のリリースに遅れ、Unicode 15.1 で割当済みの Extension I(U+2EBF0..U+2EE5F)も .NET 8 では依然として Cn と報告されるため、Cn を除外すると実在 CJK が静かに回帰する。unicode61 自身のトークナイザテーブルもブロック範囲内の予約領域を単語文字として保持するため、prefix 昇格は正しい挙動になる(コンテンツにそのコードポイントが実際に現れるときだけヒット)。ハングル音節・Jamo、半角カナから半角ハングルまでの半角形 (U+FF65..U+FFDC) をカバーする。prefix 昇格は CJK トークンに対して無条件なので `search 計算する` は `計算する追加` も返す — 厳密一致が必要なら `exact` フラグを使う。CJK 以外の非 ASCII トークン(`naïve` 等の Latin-diacritic、Greek、Cyrillic、絵文字混在)は過度な拡張を避けるため CJK prefix 経路を通さない。絵文字混在トークンは、unicode61 が indexing とクエリの両側で絵文字を削ぐため FTS 層で素の ASCII トークンと区別できず(`foo🎉` は FTS 上 `foo` と等価)、絵文字単独の部分一致も同じ理由で 0 件になる。絵文字の同一性が必要な場合は `exact` を使う。", new JsonObject { ["type"] = "object", diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 58ae86a333..5119604213 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -234,6 +234,559 @@ public void Search_RawQuerySupportsFtsPrefixSyntax() Assert.Equal("src/auth.py", results[0].Path); } + [Fact] + public void Search_FindsCjkSubstringInsideLongerToken() + { + // FTS5 default tokenizer (unicode61) treats an entire CJK run like "計算する" as one token. + // Without the CJK-only prefix-match fallback, `search 計算` would miss `def 計算する`. + // FTS5既定のunicode61トークナイザは「計算する」を単一トークンとして扱う。 + // CJK 限定の prefix match fallback を付与しない限り、`search 計算` は `def 計算する` を取りこぼす。 + InsertIndexedFile("src/cjk.py", "python", + "def 計算する(値):\n return 値 * 2\n"); + + var results = _reader.Search("計算"); + + Assert.Contains(results, r => r.Path == "src/cjk.py"); + } + + [Fact] + public void Search_CjkFullTokenQueryStillFindsExactFullToken() + { + // Positive regression: the CJK prefix fallback must not break the case where the + // query already IS the full token. Searching '計算する' must still find content + // containing '計算する'. This pins the "CJK prefix fallback does not regress exact + // matches", NOT "CJK prefix fallback narrows to exact matches" — CJK tokens always + // take the prefix path, so '計算する' also matches '計算する追加' (that widening is + // intentional; users who want strict equality should use `--exact` / `instr`). + // 正の回帰テスト: クエリが既に完全トークンの場合でも CJK prefix fallback が壊していないこと。 + // 「完全一致を保つ」ではなく「完全一致を取りこぼさない」ことを固定する点に注意 — CJK トークンは + // 常に prefix 経路を通るので '計算する' は '計算する追加' にもヒットする(意図的な挙動で、 + // 厳密一致が必要なら `--exact` / `instr` 経路を使う)。 + InsertIndexedFile("src/cjk_exact.py", "python", + "def 計算する(値):\n return 値\n"); + + var results = _reader.Search("計算する"); + + Assert.Contains(results, r => r.Path == "src/cjk_exact.py"); + } + + [Fact] + public void Search_CjkFullTokenQueryAlsoWidensToLongerTokens() + { + // Pins the intentional widening: a CJK query ALWAYS takes the prefix fallback, so + // searching '計算する' also returns chunks containing '計算する追加'. This is the + // documented semantics — users who need strict equality must use the exact path. + // If a future change wants to narrow full-token CJK queries to non-prefix, this + // test should be the first to break and force an explicit decision, not a silent + // drift. Without this pin, issue #198 could be "fixed" by a revert that re-breaks + // the original reproduction (search 計算 → 0 hits) and no test would catch it. + // 意図的なワイドニングを固定する: CJK クエリは必ず prefix fallback を通るため、 + // '計算する' の検索は '計算する追加' を含むチャンクも返す。厳密一致が必要な場合は + // exact 経路を使うのが仕様。将来この挙動を変更する場合にこのテストが最初に壊れ、 + // 静かに #198 の元再現(search 計算 → 0 件)に戻されることを防ぐアンカー。 + InsertIndexedFile("src/cjk_widen_short.py", "python", + "def 計算する(値):\n return 値\n"); + InsertIndexedFile("src/cjk_widen_long.py", "python", + "def 計算する追加(値):\n return 値 + 1\n"); + + var results = _reader.Search("計算する"); + + Assert.Contains(results, r => r.Path == "src/cjk_widen_short.py"); + Assert.Contains(results, r => r.Path == "src/cjk_widen_long.py"); + } + + [Fact] + public void Search_CjkPrefixDoesNotMatchUnrelatedCjkTokens() + { + // The CJK prefix fallback must widen only to tokens that literally start with the + // query codepoints. An unrelated CJK word like '検索' must not match '計算' even + // though both are CJK single-token runs under unicode61. + // CJK prefix fallback はクエリのコードポイントから始まるトークンにのみ拡張されるべき。 + // '検索' のような無関係なCJK語は、同じくunicode61で単一トークン扱いされても '計算' にマッチしてはならない。 + InsertIndexedFile("src/cjk_match.py", "python", + "def 計算する(値):\n return 値\n"); + InsertIndexedFile("src/cjk_unrelated.py", "python", + "def 検索する(値):\n return 値\n"); + + var results = _reader.Search("計算"); + + Assert.Contains(results, r => r.Path == "src/cjk_match.py"); + Assert.DoesNotContain(results, r => r.Path == "src/cjk_unrelated.py"); + } + + [Fact] + public void Search_EmojiMixedTokenDoesNotPrefixWidenToAsciiNeighbors() + { + // Regression guard for the most damaging over-widening case: if an emoji-mixed + // token was auto-upgraded to a prefix phrase (earlier in this fix's iterations it + // was), unicode61 would strip the emoji and the query would become a pure ASCII + // prefix search ('"foo"*') — sweeping in unrelated neighbors like 'foobar'. The + // sanitizer must therefore NOT add a prefix '*' to emoji-mixed tokens. Note: this + // only protects against PREFIX widening (neighbors that merely start with the + // ASCII fragment). It does NOT and cannot claim "exact-phrase semantics" against + // content where unicode61 indexes an identical ASCII token — see the companion + // `Search_EmojiMixedTokenFallsBackToAsciiToken_UseExactForStrict` pin. + // 最大の over-widening 回帰防止: emoji 混在トークンに prefix '*' が付くと、unicode61 が + // emoji を drop するため実質 '"foo"*' となり 'foobar' のような無関係な近傍を拾う。 + // サニタイザは emoji 混在トークンに prefix を付与してはならない。ただしこれは + // 「prefix 拡張を防ぐ」までで、unicode61 が同じ ASCII トークンを indexing した内容に + // 対して完全一致を保証するものではない(下記の companion pin を参照)。 + InsertIndexedFile("src/emoji_mixed.py", "python", + "def foo🎉():\n return 1\n"); + InsertIndexedFile("src/ascii_prefix_neighbor.py", "python", + "def foobar():\n return 2\n"); + + var results = _reader.Search("foo🎉"); + + Assert.Contains(results, r => r.Path == "src/emoji_mixed.py"); + Assert.DoesNotContain(results, r => r.Path == "src/ascii_prefix_neighbor.py"); + } + + [Fact] + public void Search_EmojiMixedTokenFallsBackToAsciiToken_UseExactForStrict() + { + // Known limitation pin: unicode61 drops emoji codepoints during BOTH indexing and + // query tokenization, so 'foo🎉' is indexed as the FTS token 'foo' and a literal + // query 'foo🎉' is tokenized as the FTS phrase '"foo"'. The FTS path therefore + // cannot distinguish between `def foo():` and `def foo🎉():` — both are FTS-equal. + // Users who need strict equality over emoji must route through the exact-substring + // path (`--exact` on the CLI, which uses SQLite `instr` against raw content and + // bypasses unicode61 tokenization entirely). This test pins that limitation so + // documentation and CHANGELOG cannot silently claim "exact-phrase semantics". + // 既知の制限の固定: unicode61 は indexing とクエリの両段階で emoji を drop するため、 + // 'foo🎉' は FTS トークンとしては 'foo' と同じになる。FTS 経路では `def foo():` と + // `def foo🎉():` を区別できず、完全一致が必要なら `--exact` 経路(SQLite `instr`)を + // 使う必要がある。文書・CHANGELOG がこの制限を見落として「完全一致を保つ」と誤って + // 謳わないよう、挙動を明示的に固定する。 + InsertIndexedFile("src/emoji_mixed_fallback.py", "python", + "def foo🎉():\n return 1\n"); + InsertIndexedFile("src/ascii_exact_twin.py", "python", + "def foo():\n return 3\n"); + + var ftsResults = _reader.Search("foo🎉"); + + // FTS path cannot distinguish — both show up because unicode61 drops '🎉' on both sides. + // FTS 経路では区別できない — unicode61 が両側で '🎉' を drop するため。 + Assert.Contains(ftsResults, r => r.Path == "src/emoji_mixed_fallback.py"); + Assert.Contains(ftsResults, r => r.Path == "src/ascii_exact_twin.py"); + + // The exact path DOES distinguish via instr() on raw content. + // exact 経路は instr() により区別できる。 + var exactResults = _reader.Search("foo🎉", exact: true); + Assert.Contains(exactResults, r => r.Path == "src/emoji_mixed_fallback.py"); + Assert.DoesNotContain(exactResults, r => r.Path == "src/ascii_exact_twin.py"); + } + + [Fact] + public void Search_LatinDiacriticTokenDoesNotWidenToPrefixSearch() + { + // Latin-diacritic tokens (e.g. 'naïve') are tokenized normally by unicode61, + // so the CJK-only prefix fallback must NOT fire. Otherwise a literal 'naïve' + // query would silently widen to match 'naïvety', 'naïveness', etc. + // Latin系ダイアクリティカル付きトークン(例: 'naïve')はunicode61で通常トークン化されるため、 + // CJK限定のprefix fallbackが発動してはならない。発動すると 'naïve' が 'naïvety' 等まで広がる。 + InsertIndexedFile("src/latin_exact.py", "python", + "def naïve():\n return 1\n"); + InsertIndexedFile("src/latin_longer.py", "python", + "def naïvety():\n return 2\n"); + + var results = _reader.Search("naïve"); + + Assert.Contains(results, r => r.Path == "src/latin_exact.py"); + Assert.DoesNotContain(results, r => r.Path == "src/latin_longer.py"); + } + + [Fact] + public void Search_FindsNonBmpCjkExtensionHSubstringInsideLongerToken() + { + // Regression guard for CJK Unified Ideographs Extension H (U+31350..U+323AF, + // added in Unicode 15.0). These codepoints are non-BMP (supplementary plane) so + // they surface in .NET strings as surrogate pairs. If the predicate only walks + // chars instead of runes, or forgets Extension H's range, `search '𱍐'` returns + // 0 results against content containing `𱍐abc` — the exact #198 zero-hit shape, + // reproduced on a newer Unicode block. Pin that a CJK Extension H query still + // takes the prefix path and finds longer-token content. + // CJK Extension H (U+31350..U+323AF, Unicode 15.0) の回帰テスト。 + // これらは非 BMP(補助面)のコードポイントで、.NET の string ではサロゲートペアとして + // 現れる。述語が char 走査だったり Extension H を忘れていたりすると、`search '𱍐'` が + // `𱍐abc` を含む内容に対して 0 件を返す — #198 の元症状そのものが新ブロックで再発する。 + // CJK Extension H クエリが prefix 経路を通り、長いトークンの内容も見つけることを固定する。 + var extensionHChar = char.ConvertFromUtf32(0x31350); + InsertIndexedFile("src/ext_h.py", "python", + $"def {extensionHChar}abc(x):\n return x\n"); + + var results = _reader.Search(extensionHChar); + + Assert.Contains(results, r => r.Path == "src/ext_h.py"); + } + + [Fact] + public void Search_FindsNonBmpCjkExtensionISubstringInsideLongerToken() + { + // Regression guard for CJK Unified Ideographs Extension I (U+2EBF0..U+2EE5F, + // Unicode 15.1, added 2023). Same non-BMP / surrogate-pair concern as Extension H, + // pinned separately so that a later "cleanup" dropping either range would break + // its own dedicated test instead of silently regressing. + // CJK Extension I (U+2EBF0..U+2EE5F, Unicode 15.1) の回帰テスト。 + // Extension H と同じく非 BMP だが、どちらかの範囲を「整理」で外すとそれぞれ固有の + // テストが壊れるよう、別テストとして固定する。 + var extensionIChar = char.ConvertFromUtf32(0x2EBF0); + InsertIndexedFile("src/ext_i.py", "python", + $"def {extensionIChar}abc(x):\n return x\n"); + + var results = _reader.Search(extensionIChar); + + Assert.Contains(results, r => r.Path == "src/ext_i.py"); + } + + [Fact] + public void Search_FindsIdeographicIterationMarkInsideLongerToken() + { + // Regression guard for Han-script codepoints outside the CJK Unified Ideographs + // blocks. '々' (U+3005, ideographic iteration mark) is Unicode script=Han but lives + // in the CJK Symbols and Punctuation block. unicode61 keeps it as a word character, + // so without explicit inclusion in the CJK prefix fallback set, `search '々'` returns + // 0 results against content containing `々abc` — same shape as #198 on a different + // codepoint class. + // CJK Unified Ideographs 範囲外の Han script コードポイントの回帰テスト。'々' (U+3005) は + // Unicode script=Han だが CJK Symbols and Punctuation ブロックに属する。unicode61 では + // 単語文字扱いなので、CJK prefix fallback セットに明示的に含めないと `search '々'` が + // `々abc` を含むファイルに対し 0 件を返す — #198 の別コードポイント版。 + InsertIndexedFile("src/iter_mark.py", "python", + "def 々abc(x):\n return x\n"); + + var results = _reader.Search("々"); + + Assert.Contains(results, r => r.Path == "src/iter_mark.py"); + } + + [Fact] + public void Search_FindsIdeographicZeroInsideLongerToken() + { + // Same concern as 々 above but for '〇' (U+3007, ideographic number zero). + // 上の 々 と同様、'〇' (U+3007) についての回帰テスト。 + InsertIndexedFile("src/ideograph_zero.py", "python", + "def 〇abc(x):\n return x\n"); + + var results = _reader.Search("〇"); + + Assert.Contains(results, r => r.Path == "src/ideograph_zero.py"); + } + + [Fact] + public void Search_FindsHalfwidthHangulSubstringInsideLongerToken() + { + // Regression guard for halfwidth Hangul letters (U+FFA0..U+FFDC). unicode61 keeps + // them as word characters, so without including that range in the CJK prefix + // fallback, `search 'ᄆ'` returns 0 results against content containing 'ᄆᄇᄈabc'. + // This is the same 0-hit shape as #198 on the halfwidth Hangul block, which is + // why the halfwidth range extends past U+FF9F (halfwidth Katakana) to U+FFDC. + // 半角ハングル (U+FFA0..U+FFDC) の回帰テスト。unicode61 は単語文字として扱うため、 + // CJK prefix fallback 範囲に含めないと `search 'ᄆ'` が 'ᄆᄇᄈabc' を含む内容に対して + // 0 件になる — 半角ハングル版の #198 再現。U+FF9F までではなく U+FFDC まで広げる + // 必要がある理由を固定する。 + InsertIndexedFile("src/halfwidth_hangul.py", "python", + "def ᄆᄇᄈabc(x):\n return x\n"); + + var results = _reader.Search("ᄆ"); + + Assert.Contains(results, r => r.Path == "src/halfwidth_hangul.py"); + } + + [Fact] + public void Search_FindsVerticalKanaRepeatMarkInsideLongerToken() + { + // Regression guard for the vertical kana repeat mark block (U+3031..U+3035), Unicode + // category Lm (Letter Modifier). These codepoints are used in vertical-text Japanese + // as iteration marks. unicode61 keeps them as word characters, so without explicit + // inclusion in the CJK prefix fallback set, `search '〱'` returns 0 results against + // content containing `〱abc` — same shape as #198 on another Japanese block. + // 縦書き仮名反復記号(U+3031..U+3035、Unicode カテゴリ Lm)の回帰テスト。unicode61 では + // 単語文字として扱われるため、CJK prefix fallback セットに明示的に含めないと + // `search '〱'` が `〱abc` に対して 0 件を返す — 別ブロック版の #198 再現。 + InsertIndexedFile("src/vertical_kana.py", "python", + "def 〱abc(x):\n return x\n"); + + var results = _reader.Search("〱"); + + Assert.Contains(results, r => r.Path == "src/vertical_kana.py"); + } + + [Fact] + public void Search_FindsBopomofoInsideLongerToken() + { + // Regression guard for Bopomofo (U+3100..U+312F), the Mandarin Chinese phonetic + // system ("zhuyin"). Bopomofo letters are Unicode category Lo and survive unicode61 + // tokenization as regular word characters, so a bare phrase query like `search 'ㄅ'` + // used to return 0 against content containing `ㄅabc` — same shape as the original + // #198 repro but on a different script. Pin that Bopomofo queries take the prefix + // fallback path. + // 注音符号(ボポモフォ、U+3100..U+312F、中国語発音)の回帰テスト。Unicode カテゴリ Lo で + // unicode61 は単語文字として保つため、`search 'ㄅ'` が `ㄅabc` に対して 0 件を返す + // 状態を防ぐ — #198 を別スクリプトで再現した形。 + InsertIndexedFile("src/bopomofo.py", "python", + "def ㄅabc(x):\n return x\n"); + + var results = _reader.Search("ㄅ"); + + Assert.Contains(results, r => r.Path == "src/bopomofo.py"); + } + + [Fact] + public void Search_FindsBopomofoExtendedInsideLongerToken() + { + // Regression guard for Bopomofo Extended (U+31A0..U+31BF), which extends zhuyin with + // additional phonetic letters used for minority Chinese dialects (e.g. Min Nan, Hakka). + // Same category / tokenization concern as Bopomofo above; pinned separately so a later + // cleanup that drops either range breaks its own dedicated test. + // 拡張注音符号(U+31A0..U+31BF、閩南語や客家語等の発音)の回帰テスト。Bopomofo と同じく + // 単語文字扱いなので、それぞれの範囲を独立に固定する。 + InsertIndexedFile("src/bopomofo_ext.py", "python", + "def ㆠabc(x):\n return x\n"); + + var results = _reader.Search("ㆠ"); + + Assert.Contains(results, r => r.Path == "src/bopomofo_ext.py"); + } + + [Fact] + public void Search_FindsYiSyllableInsideLongerToken() + { + // Regression guard for Yi Syllables (U+A000..U+A48F), the syllabary used by the Nuosu + // (Yi) people in southwestern China. Yi syllables are Unicode category Lo, so unicode61 + // keeps them as word characters; without CJK prefix promotion, `search 'ꀀ'` returns 0 + // results against content containing 'ꀀabc' — same 0-hit shape as #198 on another + // Unicode-15-adjacent historical East Asian script. Yi Radicals (U+A490..U+A4CF) are + // intentionally excluded upstream because they are category So and dropped by unicode61. + // 彝文字音節(Yi Syllables、U+A000..U+A48F、中国南西部のノス族の文字体系)の回帰テスト。 + // Unicode カテゴリ Lo で unicode61 は単語文字として扱うため、CJK prefix 昇格なしでは + // `search 'ꀀ'` が 'ꀀabc' を含む内容に対して 0 件を返す — #198 の別スクリプト版。 + // 彝文字部首(Yi Radicals、U+A490..U+A4CF)は Unicode カテゴリ So のため上流で意図的に除外。 + InsertIndexedFile("src/yi_syllables.py", "python", + "def ꀀabc(x):\n return x\n"); + + var results = _reader.Search("ꀀ"); + + Assert.Contains(results, r => r.Path == "src/yi_syllables.py"); + } + + [Fact] + public void Search_FindsNonBmpTangutSubstringInsideLongerToken() + { + // Regression guard for Tangut (U+17000..U+187FF), a non-BMP historical East Asian + // logographic script used by the Western Xia empire (11th–13th century). Tangut is + // Unicode category Lo and unicode61 keeps it as word characters, so the surrogate-pair + // aware rune walk AND an explicit U+17000..U+187FF range entry are both required for + // `search '𗀀'` to match '𗀀abc'. Pinned as a dedicated Tangut-block test so a future + // rewrite that collapses Tangut into a different neighboring block breaks here, not + // silently on Chinese archaeological text. + // 西夏文字(Tangut、U+17000..U+187FF、西夏帝国の非 BMP 表意文字)の回帰テスト。 + // Unicode カテゴリ Lo で unicode61 が単語文字として扱うため、rune 走査と U+17000..U+187FF + // 範囲の CJK prefix 包含の両方がないと `search '𗀀'` が '𗀀abc' を拾えない。Tangut ブロック + // 単独のテストとして固定し、将来別ブロックへ統合するような書き換えがあっても中国考古 + // テキストで黙って回帰する前にここで壊れるようにする。 + var tangutChar = char.ConvertFromUtf32(0x17000); + InsertIndexedFile("src/tangut.py", "python", + $"def {tangutChar}abc(x):\n return x\n"); + + var results = _reader.Search(tangutChar); + + Assert.Contains(results, r => r.Path == "src/tangut.py"); + } + + [Fact] + public void Search_FindsNonBmpTangutComponentsSubstringInsideLongerToken() + { + // Regression guard for Tangut Components (U+18800..U+18AFF), the non-BMP block of + // radical / stroke components used to build Tangut logographs. Separate Unicode block + // and separate predicate branch from Tangut itself, so this test exercises its own + // branch rather than aliasing to the Tangut test. Unicode category Lo; unicode61 keeps + // it as word characters; same #198 zero-hit shape without explicit prefix fallback. + // 西夏文字部品(Tangut Components、U+18800..U+18AFF、非 BMP の西夏文字構成要素)の + // 回帰テスト。Tangut 本体とは別の Unicode ブロック・別の分岐を踏むため、Tangut テストと + // エイリアス化せず専用分岐を検証する。Unicode カテゴリ Lo で unicode61 は単語文字として + // 扱うため、prefix fallback なしでは #198 と同じ 0 件症状になる。 + var tangutComponentsChar = char.ConvertFromUtf32(0x18800); + InsertIndexedFile("src/tangut_components.py", "python", + $"def {tangutComponentsChar}abc(x):\n return x\n"); + + var results = _reader.Search(tangutComponentsChar); + + Assert.Contains(results, r => r.Path == "src/tangut_components.py"); + } + + [Fact] + public void Search_FindsNonBmpKhitanSmallScriptSubstringInsideLongerToken() + { + // Regression guard for Khitan Small Script (U+18B00..U+18CFF), the non-BMP script of + // the Liao dynasty's Khitan people (10th–13th century). Separate Unicode block and + // separate predicate branch from Tangut / Tangut Components / Tangut Supplement, so + // this test exercises its own branch. Unicode category Lo; unicode61 keeps it as + // word characters. + // 契丹小字(Khitan Small Script、U+18B00..U+18CFF、遼朝の非 BMP 表音文字)の回帰テスト。 + // Tangut / Tangut Components / Tangut Supplement とは別の Unicode ブロック・別の分岐。 + // Unicode カテゴリ Lo で unicode61 は単語文字として扱う。 + var khitanChar = char.ConvertFromUtf32(0x18B00); + InsertIndexedFile("src/khitan_small.py", "python", + $"def {khitanChar}abc(x):\n return x\n"); + + var results = _reader.Search(khitanChar); + + Assert.Contains(results, r => r.Path == "src/khitan_small.py"); + } + + [Fact] + public void Search_FindsNonBmpTangutSupplementSubstringInsideLongerToken() + { + // Regression guard for Tangut Supplement (U+18D00..U+18D8F), the small non-BMP block + // added in Unicode 13.0 alongside Khitan Small Script. Separate predicate branch from + // Tangut / Tangut Components / Khitan, so this test exercises its own branch. Unicode + // category Lo; unicode61 keeps it as word characters. + // 西夏文字補助(Tangut Supplement、U+18D00..U+18D8F、Unicode 13.0 で Khitan Small Script と + // 同時追加された小規模な非 BMP ブロック)の回帰テスト。Tangut / Tangut Components / + // Khitan とは別の分岐。Unicode カテゴリ Lo で unicode61 は単語文字として扱う。 + var tangutSupplementChar = char.ConvertFromUtf32(0x18D00); + InsertIndexedFile("src/tangut_supplement.py", "python", + $"def {tangutSupplementChar}abc(x):\n return x\n"); + + var results = _reader.Search(tangutSupplementChar); + + Assert.Contains(results, r => r.Path == "src/tangut_supplement.py"); + } + + [Fact] + public void Search_FindsNonBmpTangutIterationMarkInsideLongerToken() + { + // Regression guard for the Tangut Iteration Mark (U+16FE0), a non-BMP codepoint in the + // Ideographic Symbols and Punctuation block used to annotate repeated Tangut + // characters. Unicode category Lm (Modifier Letter) on the current runtime; unicode61 + // keeps Lm codepoints as word characters, so `search '𖿠'` returned 0 against '𖿠abc' + // without explicit CJK prefix promotion. The Ideographic Symbols and Punctuation + // iteration / annotation codepoints (U+16FE0 Tangut iteration, U+16FE1 Nüshu iteration, + // U+16FE3 Old Chinese iteration, U+16FE4 Khitan filler, U+16FF0 / U+16FF1 Vietnamese + // reading marks) are listed individually in the predicate so U+16FE2 (Po, dropped by + // unicode61) does not ride along. Pinned separately so the Lm / ideographic-annotation + // branch cannot regress silently. + // Tangut 反復記号(U+16FE0、非 BMP の Ideographic Symbols and Punctuation ブロック)の + // 回帰テスト。現行ランタイムでは Unicode カテゴリ Lm で unicode61 は単語文字として扱う。 + // そのため CJK prefix 昇格がなければ `search '𖿠'` は '𖿠abc' に対して 0 件を返す。 + // Ideographic Symbols and Punctuation の反復 / 注釈記号(U+16FE0 / 16FE1 / 16FE3 / 16FE4 + // / 16FF0 / 16FF1)は個別列挙にし、U+16FE2 (Po, unicode61 が drop) を巻き込まないように + // している。Lm / ideographic annotation 分岐の回帰をここで固定する。 + var tangutIterationMark = char.ConvertFromUtf32(0x16FE0); + InsertIndexedFile("src/tangut_iter.py", "python", + $"def {tangutIterationMark}abc(x):\n return x\n"); + + var results = _reader.Search(tangutIterationMark); + + Assert.Contains(results, r => r.Path == "src/tangut_iter.py"); + } + + [Fact] + public void Search_FindsNonBmpKhitanSmallScriptFillerInsideLongerToken() + { + // Regression guard for U+16FE4 (Khitan Small Script Filler), a non-BMP codepoint in the + // Ideographic Symbols and Punctuation block. On the current runtime this is Unicode + // category Mn (Nonspacing Mark); unicode61 still keeps Mn codepoints as word + // characters, so `search '𖿤'` returned 0 against '𖿤abc' without explicit CJK prefix + // promotion. Pinned so the Mn / Khitan-annotation case is covered in addition to Lm. + // 契丹小字フィラー(U+16FE4、非 BMP の Ideographic Symbols and Punctuation ブロック)の + // 回帰テスト。現行ランタイムでは Unicode カテゴリ Mn。unicode61 は Mn も単語文字として + // 扱うため、CJK prefix 昇格がなければ `search '𖿤'` は '𖿤abc' に対して 0 件を返す。 + // Lm だけでなく Mn / 契丹注釈のケースも別途固定する。 + var khitanFiller = char.ConvertFromUtf32(0x16FE4); + InsertIndexedFile("src/khitan_filler.py", "python", + $"def {khitanFiller}abc(x):\n return x\n"); + + var results = _reader.Search(khitanFiller); + + Assert.Contains(results, r => r.Path == "src/khitan_filler.py"); + } + + [Fact] + public void Search_FindsNonBmpVietnameseReadingMarkInsideLongerToken() + { + // Regression guard for U+16FF0 (Vietnamese Alternate Reading Mark CA), a non-BMP + // codepoint in the Ideographic Symbols and Punctuation block used to annotate Chu Nom + // (Han-based Vietnamese) text. On the current runtime this is Unicode category Mc + // (Spacing Mark); unicode61 keeps Mc codepoints as word characters, so `search '𖿰'` + // returned 0 against '𖿰abc' without explicit CJK prefix promotion. Pinned so the Mc / + // Chu-Nom-annotation case is covered in addition to Lm / Mn. + // ベトナム語 Chu Nom 読み記号 CA(U+16FF0、非 BMP の Ideographic Symbols and Punctuation + // ブロック)の回帰テスト。現行ランタイムでは Unicode カテゴリ Mc。unicode61 は Mc も + // 単語文字として扱うため、CJK prefix 昇格がなければ `search '𖿰'` は '𖿰abc' に対して + // 0 件を返す。Lm / Mn だけでなく Mc / Chu Nom 注釈のケースも固定する。 + var vietnameseReadingMark = char.ConvertFromUtf32(0x16FF0); + InsertIndexedFile("src/vietnamese_ca.py", "python", + $"def {vietnameseReadingMark}abc(x):\n return x\n"); + + var results = _reader.Search(vietnameseReadingMark); + + Assert.Contains(results, r => r.Path == "src/vietnamese_ca.py"); + } + + [Fact] + public void Search_FindsNonBmpNushuSubstringInsideLongerToken() + { + // Regression guard for Nüshu (U+1B170..U+1B2FF), a non-BMP syllabic script historically + // used by women in Jiangyong County, Hunan, China. Unicode category Lo; unicode61 keeps + // it as word characters. Non-BMP, so the same surrogate-pair-aware rune walk and explicit + // range inclusion required for Tangut also apply here. Without them, `search '𛅰'` returns + // 0 against '𛅰abc' — #198 repeated on another non-BMP historical East Asian script. + // 女書(Nüshu、U+1B170..U+1B2FF、中国湖南省江永県で女性たちが使った非 BMP 音節文字)の + // 回帰テスト。Unicode カテゴリ Lo で unicode61 は単語文字として扱う。非 BMP のため、 + // Tangut と同じく rune 走査と範囲追加の両方が必要。抜けると `search '𛅰'` が '𛅰abc' に + // 対して 0 件を返す — #198 の非 BMP 歴史的東アジア文字版。 + var nushuChar = char.ConvertFromUtf32(0x1B170); + InsertIndexedFile("src/nushu.py", "python", + $"def {nushuChar}abc(x):\n return x\n"); + + var results = _reader.Search(nushuChar); + + Assert.Contains(results, r => r.Path == "src/nushu.py"); + } + + [Fact] + public void Search_FindsNonBmpKanaExtendedBSubstringInsideLongerToken() + { + // Regression guard for Kana Extended-B (U+1AFF0..U+1AFFF, Unicode 15.0). Non-BMP + // kana codepoints are represented as surrogate pairs in .NET strings; the predicate + // must walk runes rather than chars AND must include this range in the fallback + // set. Without it, `search '𚿰'` returns 0 results against content containing + // '𚿰abc' — identical 0-hit shape to #198. + // Kana Extended-B (U+1AFF0..U+1AFFF, Unicode 15.0) の回帰テスト。非 BMP の仮名は + // .NET 文字列ではサロゲートペアとして現れるため、述語は rune を走査し、さらにこの + // 範囲を fallback セットに含める必要がある。抜けると `search '𚿰'` が '𚿰abc' を含む + // 内容に対して 0 件を返す — #198 と同じ症状が Kana Extended-B で再発する。 + var kanaExtendedBChar = char.ConvertFromUtf32(0x1AFF0); + InsertIndexedFile("src/kana_ext_b.py", "python", + $"def {kanaExtendedBChar}abc(x):\n return x\n"); + + var results = _reader.Search(kanaExtendedBChar); + + Assert.Contains(results, r => r.Path == "src/kana_ext_b.py"); + } + + [Fact] + public void CountSearchResults_IncludesCjkSubstringMatches() + { + // Count path shares the sanitizer, so the CJK prefix fallback must apply there too. + // Pin the exact count/fileCount instead of a loose `>= 1` so drift that inflates + // the count (e.g. prefix promotion leaking into an unrelated file) is caught too. + // カウント経路も同じサニタイザを共有するため、CJKの prefix フォールバックが効く必要がある。 + // 緩い `>= 1` ではなく厳密な count/fileCount を固定し、prefix 昇格が無関係なファイルに + // 漏れて count が膨らむようなドリフトも捕える。 + InsertIndexedFile("src/cjk_count_hit.py", "python", + "def 計算する(値):\n return 値\n"); + InsertIndexedFile("src/cjk_count_miss.py", "python", + "def 検索する(値):\n return 値\n"); + + var counts = _reader.CountSearchResults("計算"); + + Assert.Equal(1, counts.Count); + Assert.Equal(1, counts.FileCount); + } + [Fact] public void SearchSymbols_FindsByName() {