wp2txt 2.3.0 → 2.3.2

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.
data/README.md CHANGED
@@ -84,7 +84,7 @@ docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
84
84
 
85
85
  The `wp2txt` command is available inside the container. Use `/data` for input/output files.
86
86
 
87
- Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`); Docker Hub (`yohasebe/wp2txt`) is maintained as a mirror.
87
+ Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`). The former Docker Hub repository (`yohasebe/wp2txt`) is no longer updated as of 2.3.1.
88
88
 
89
89
  **MCP server (no Ruby required on the host):**
90
90
 
@@ -255,12 +255,12 @@ By default, citation templates are removed. Use `--extract-citations` to extract
255
255
 
256
256
  Supported: `{{cite book}}`, `{{cite web}}`, `{{cite news}}`, `{{cite journal}}`, `{{Citation}}`, etc.
257
257
 
258
- ## Research Infrastructure (Indexes, Exhaustive Queries, MCP)
258
+ ## Offline Indexes, Queries, and the MCP Server
259
259
 
260
- Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned research
261
- database**: SQLite indexes over categories, section headings, redirects, and (optionally)
262
- the full article text, plus interlanguage links for cross-edition comparison — all
263
- queryable offline, exhaustively, and exposed to LLM agents via an MCP server.
260
+ Beyond text extraction, wp2txt can turn a dump into a **local, version-pinned database**:
261
+ SQLite indexes over categories, section headings, redirects, and (optionally) the full
262
+ article text, plus interlanguage links for cross-edition comparison — all queryable
263
+ offline, and usable from an LLM client via an MCP server.
264
264
 
265
265
  ```console
266
266
  $ wp2txt --build-index --fulltext --lang=ja # build the indexes
@@ -270,13 +270,12 @@ $ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
270
270
  $ wp2txt-mcp --lang=ja # stdio MCP server for LLM agents
271
271
  ```
272
272
 
273
- Unlike web/API access, these queries scan every article (a `0 matches` result is a
274
- verifiable absence claim for that dump version) and are reproducible: extractions record
275
- the dump version and query in a `.meta.json` sidecar.
273
+ These queries scan every article `0 matches` means the term is absent from that dump
274
+ version and extractions record the dump version and query in a `.meta.json` sidecar,
275
+ so results can be reproduced later.
276
276
 
277
- **→ See the [Research Infrastructure Guide](docs/RESEARCH.md)** for index building,
278
- exhaustive queries, full-text search, interlanguage links, cross-language SQL, the full
279
- MCP tool list, and design principles.
277
+ **→ See [docs/INDEXES.md](docs/INDEXES.md)** for index building, offline queries,
278
+ full-text search, interlanguage links, cross-language SQL, and the full MCP tool list.
280
279
 
281
280
  ## Command Line Options
282
281
 
@@ -354,11 +353,11 @@ MCP tool list, and design principles.
354
353
  ### Research infrastructure options
355
354
 
356
355
  --build-index Build the metadata index (add --fulltext for FTS)
357
- --find-articles / --search Exhaustive offline queries (see docs/RESEARCH.md)
356
+ --find-articles / --search Offline queries over a whole edition (see docs/INDEXES.md)
358
357
  --import-langlinks Import interlanguage links (version-matched)
359
358
  --fts-optimize Optimize an existing full-text index
360
359
 
361
- See the [Research Infrastructure Guide](docs/RESEARCH.md) for details.
360
+ See [docs/INDEXES.md](docs/INDEXES.md) for details.
362
361
 
363
362
  ## Configuration File
364
363
 
data/README_ja.md CHANGED
@@ -34,6 +34,8 @@ WP2TXTはWikipediaダンプファイルからプレーンテキストとカテ
34
34
  - **多言語対応** - 350以上のWikipedia言語でカテゴリ・リダイレクトを検出
35
35
  - **ストリーミング処理** - 中間ファイルなしで大規模ダンプを処理
36
36
  - **JSON出力** - データパイプライン向けの機械可読JSONL形式
37
+ - **オフライン研究索引** - カテゴリ・節見出し・全文に対する悉皆的・版固定クエリ、言語間リンクによる言語版横断SQL
38
+ - **MCPサーバー** - ローカルダンプをLLMエージェント(Claude、ChatGPT、Gemini、ローカルモデル)に公開
37
39
 
38
40
  ## ユースケース
39
41
 
@@ -43,6 +45,8 @@ wp2txtは以下の用途に適しています:
43
45
  - トピック領域を横断した比較言語研究
44
46
  - NLPタスク向けのメタデータ付きWikipediaテキスト抽出
45
47
  - 並行カテゴリ構造を利用した対照言語研究
48
+ - 版固定のRAG知識ベース・LLM評価データセット
49
+ - web検索では原理的に不可能な悉皆的言明(「このカテゴリのどの記事にもXへの言及がない」)
46
50
 
47
51
  ## データアクセス
48
52
 
@@ -71,11 +75,13 @@ Windows:[Bzip2 for Windows](http://gnuwin32.sourceforge.net/packages/bzip2.htm
71
75
  ### Docker(代替方法)
72
76
 
73
77
  ```shell
74
- docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
78
+ docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
75
79
  ```
76
80
 
77
81
  `wp2txt`コマンドはコンテナ内で使用可能です。入出力には`/data`ディレクトリを使用してください。
78
82
 
83
+ イメージはGitHub Container Registry(`ghcr.io/yohasebe/wp2txt`)で公開されています。旧 Docker Hub リポジトリ(`yohasebe/wp2txt`)は 2.3.1 以降更新されません。
84
+
79
85
  ## 基本的な使い方
80
86
 
81
87
  ### 自動ダウンロードと処理(推奨)
@@ -327,6 +333,28 @@ defaults:
327
333
 
328
334
  コマンドラインオプションは設定ファイルの設定を上書きします。
329
335
 
336
+ ## オフライン索引・クエリ・MCPサーバー
337
+
338
+ テキスト抽出に加えて、wp2txtはダンプを**ローカルな版固定データベース**に変換できます。
339
+ カテゴリ・節見出し・リダイレクト・(オプションで)記事全文のSQLite索引、言語版横断比較のための
340
+ 言語間リンク(langlinks)— すべてオフラインで悉皆的にクエリでき、MCPサーバー経由で
341
+ LLMエージェントにも公開できます。
342
+
343
+ ```console
344
+ $ wp2txt --build-index --fulltext --lang=ja # 索引の構築
345
+ $ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
346
+ $ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
347
+ $ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
348
+ $ wp2txt-mcp --lang=ja # LLMエージェント向けMCPサーバー
349
+ ```
350
+
351
+ これらのクエリは全記事を走査します(`0件` は当該ダンプ版にその語が存在しないことを
352
+ 意味します)。抽出結果には dump 版とクエリを記録した `.meta.json` サイドカーが付き、
353
+ あとから再現できます。
354
+
355
+ **→ 詳細は [docs/INDEXES.md](docs/INDEXES.md)(英語)を参照**:
356
+ 索引構築、オフラインクエリ、全文検索、言語間リンク、言語版横断SQL、MCPツール一覧。
357
+
330
358
  ## パフォーマンス
331
359
 
332
360
  MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボモード、ダウンロード時間除く):
@@ -347,6 +375,10 @@ MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボ
347
375
 
348
376
  詳細なリリースノートは[CHANGELOG.md](CHANGELOG.md)を参照してください。
349
377
 
378
+ **v2.3.0(2026年7月)**: 言語間リンク取り込み、言語版横断SQL(multi-dump ATTACH)、明示タイトル集合の抽出、SQL結果のファイル出力と再現性サイドカー、GHCRイメージ公開。
379
+
380
+ **v2.2.0(2026年7月)**: オフラインメタデータ索引、FTS5全文検索、MCPサーバー、query_sql、抽出ジョブ。
381
+
350
382
  **v2.1.0(2026年2月)**: SQLiteキャッシュ、Ractor並列処理(Ruby 4.0+)、テンプレート展開、コンテンツマーカー、Dockerイメージ更新。
351
383
 
352
384
  **v2.0.0(2026年1月)**: 自動ダウンロードモード、カテゴリベース抽出、タイトル指定抽出、JSON出力、ストリーミング処理、Ruby 4.0サポート。
data/Rakefile CHANGED
@@ -32,16 +32,39 @@ Rake::Task["build"].enhance([:normalize_permissions])
32
32
  # Docker
33
33
  # =============================================================================
34
34
 
35
- desc "Push Docker images"
36
- task :push do
35
+ # Paths that must never reach a published image. The image is built from the
36
+ # working tree, so anything ignored locally (private notes, scratch files)
37
+ # would otherwise ride along.
38
+ IMAGE_FORBIDDEN_PATHS = %w[/wp2txt/research-notes /wp2txt/tmp /wp2txt/.git /wp2txt/CLAUDE.md /wp2txt/.claude].freeze
39
+
40
+ desc "Verify a built image contains no private material (run before pushing)"
41
+ task :verify_image, [:tag] do |_t, args|
42
+ tag = args[:tag] || "wp2txt-verify:local"
43
+ checks = IMAGE_FORBIDDEN_PATHS.map { |p| "test -e #{p} && echo LEAK:#{p}" }.join("; ")
44
+ out = `docker run --rm #{tag} sh -c '#{checks}; true' 2>&1`
45
+ leaks = out.lines.grep(/^LEAK:/).map(&:strip)
46
+ abort "Image #{tag} contains private paths:\n #{leaks.join("\n ")}" unless leaks.empty?
47
+
48
+ puts "OK: #{tag} contains none of #{IMAGE_FORBIDDEN_PATHS.join(', ')}"
49
+ end
50
+
51
+ desc "Build the image locally and verify it, without pushing"
52
+ task :check_image do
53
+ sh "docker build -t wp2txt-verify:local ."
54
+ Rake::Task[:verify_image].invoke
55
+ end
56
+
57
+ desc "Build and push Docker images to GHCR (verifies a local build first)"
58
+ task push: :check_image do
59
+ # Docker Hub was retired after 2.3.0; GHCR (ghcr.io/yohasebe/wp2txt) is the
60
+ # only registry. Requires `docker buildx use multiarch` and a ghcr.io login.
37
61
  sh <<-SCRIPT.strip_heredoc, { verbose: false }
38
62
  /bin/bash -xeu <<'BASH'
39
- # docker buildx create --name mybuilder
40
- # docker buildx use mybuilder
63
+ # docker buildx create --name multiarch
64
+ # docker buildx use multiarch
41
65
  # docker buildx inspect --bootstrap
42
66
  docker buildx build --platform linux/amd64,linux/arm64 \
43
67
  -t ghcr.io/yohasebe/wp2txt:#{Wp2txt::VERSION} -t ghcr.io/yohasebe/wp2txt:latest \
44
- -t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest \
45
68
  . --push
46
69
  BASH
47
70
  SCRIPT
data/bin/wp2txt-mcp CHANGED
@@ -254,7 +254,7 @@ end
254
254
 
255
255
  server.define_tool(
256
256
  name: "save_alias_set",
257
- description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility.",
257
+ description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility. A refused save is signaled by saved:false in the RESULT (not an exception); verify persistence with list_alias_sets.",
258
258
  input_schema: {
259
259
  properties: {
260
260
  name: { type: "string" },
@@ -1,40 +1,31 @@
1
- # wp2txt Research Infrastructure Guide
1
+ # Offline Indexes, Queries, and the MCP Server
2
2
 
3
- This guide covers the research-oriented layer of wp2txt: local indexes over Wikipedia
4
- dumps, exhaustive offline queries, full-text search, cross-language SQL, and the MCP
5
- server that exposes all of this to LLM agents.
3
+ This guide covers wp2txt's index-based features: local indexes over Wikipedia dumps,
4
+ offline queries across a whole edition, full-text search, interlanguage links,
5
+ cross-language SQL, and the MCP server for connecting an LLM client.
6
6
 
7
7
  For plain-text extraction (the classic wp2txt), see the [README](../README.md).
8
8
 
9
- ## Concept
10
-
11
- Web search and the Wikipedia API operate on ranked, paginated, ever-changing data. They
12
- can show that something *exists*, but they cannot make **exhaustive** claims ("342 of the
13
- 11,486 film articles with a plot section mention X and none of the others do"), and
14
- their answers change from day to day.
15
-
16
- wp2txt takes the opposite approach: build local indexes over an official dump file, so that
17
- every query is
18
-
19
- - **exhaustive** — it scans every article, not search-ranked results;
20
- - **version-pinned** — results are tied to one dump (e.g. `jawiki-20260701`) and
21
- reproducible later;
22
- - **agent-operable** — the MCP server exposes the whole layer to LLM agents, with
23
- guardrails and provenance records designed for autonomous use.
9
+ Everything below runs against a downloaded dump file: queries cover every article of the
10
+ edition rather than a page of search results, results are tied to one dump (e.g.
11
+ `jawiki-20260701`) and can be reproduced later, and nothing goes over the network once
12
+ the dump is downloaded. Typical uses: "which of the 1.5M articles have a plot section",
13
+ "how many film articles mention X, and which ones don't", "how do the section structures
14
+ of the same article differ between the English and Japanese editions".
24
15
 
25
16
  ## 1. Building the indexes
26
17
 
27
18
  ```console
28
- # Tier 1: metadata index (categories, section headings, redirects, category hierarchy)
19
+ # Metadata index (categories, section headings, redirects, category hierarchy)
29
20
  $ wp2txt --build-index --lang=ja
30
21
 
31
- # Tier 1 + Tier 2: add an FTS5 full-text index over the cleaned article text
22
+ # Metadata index + FTS5 full-text index over the cleaned article text
32
23
  $ wp2txt --build-index --fulltext --lang=ja
33
24
  ```
34
25
 
35
26
  The dump is downloaded automatically if needed and everything is cached under
36
27
  `~/.wp2txt/cache/`. Ballpark figures (Apple Silicon laptop): Japanese Wikipedia ~15 min /
37
- ~1.7 GB for the metadata index, ~1.5 h / ~12 GB with full text; English roughly 4× the
28
+ ~1.7 GB for the metadata index, ~1.5 h / ~12 GB with full text; English roughly 2.5–3× the
38
29
  time, ~10 GB / ~12 GB respectively.
39
30
 
40
31
  The full-text tokenizer is selected per language: character trigrams for Japanese,
@@ -58,8 +49,8 @@ $ wp2txt --find-articles --in-category "Films" -D 2 -j json --limit 100 --lang=e
58
49
  $ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
59
50
  ```
60
51
 
61
- Search totals are exhaustive counts, so `0 matches` is a verifiable **absence claim** for
62
- that dump version — something ranked web search cannot provide.
52
+ Search totals count every match in the edition, so `0 matches` means the term is absent
53
+ from that dump version — a result you can state and re-verify later.
63
54
 
64
55
  ## 3. Interlanguage links (langlinks)
65
56
 
@@ -114,7 +105,7 @@ $ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yo
114
105
 
115
106
  | Tool | Purpose |
116
107
  |------|---------|
117
- | `dump_info` | Dump identity, index tiers, corpus statistics, langlinks provenance |
108
+ | `dump_info` | Dump identity, installed indexes, corpus statistics, langlinks provenance |
118
109
  | `get_article` / `get_sections` / `list_headings` / `get_categories` | Single-article access (redirect-aware) |
119
110
  | `find_articles` | Exhaustive filtered listing (category recursion, category AND / pattern match, section headings, title match) |
120
111
  | `category_tree` / `section_stats` | Scope exploration and heading-frequency discovery |
@@ -126,17 +117,19 @@ $ claude mcp add wp2txt -- docker run -i --rm -v wp2txt:/root/.wp2txt ghcr.io/yo
126
117
  | `extract_corpus` | Filtered or explicit-title extraction to JSONL + reproducibility sidecar; optional RAG chunking |
127
118
  | `start_extract_job` / `job_status` / `cancel_job` / `list_jobs` | Background jobs for large extractions |
128
119
 
129
- ### Design principles
120
+ ### What happens when your assistant uses these tools
130
121
 
131
- - **Division of labor**: the tool does mechanical, exhaustive narrowing and counting;
132
- semantic judgment is left to the LLM. The LLM never has to count.
133
- - **Context economy**: large results go to disk; the model receives a summary plus a
134
- 3-record sample, never the full corpus.
135
- - **Reproducibility**: every extraction and file-writing query records the dump version,
136
- the query, and any alias sets in a `.meta.json` sidecar.
137
- - **Guardrails**: SQL is screened and executed read-only in a killable subprocess;
138
- alias sets are re-verified server-side before saving; output paths are confined to the
139
- server's output directory.
122
+ - **Filtering and counting run over the whole dump**, and your assistant reads the result
123
+ rather than tallying articles itself.
124
+ - **Large results are written to a file**; the reply carries a summary and a short sample.
125
+ Your corpus lands on disk intact instead of being paraphrased through the chat.
126
+ - **Extractions are traceable.** Extractions and file-writing queries leave a `.meta.json`
127
+ next to the output recording the dump version, the query, and any alias sets used, so
128
+ you can reproduce or cite the result later.
129
+ - **The tools cannot change your data.** Queries run read-only and are stopped after 30
130
+ seconds, saved alias sets are re-checked before being stored, and files can only be
131
+ written under the server's output directory — worth knowing if you plan to let an
132
+ assistant work unattended.
140
133
 
141
134
  ## 5. Cross-language SQL
142
135
 
@@ -157,12 +150,12 @@ query_sql(
157
150
  ```
158
151
 
159
152
  Attached databases appear as `{lang}_meta` (and `{lang}_fts` when that language has a
160
- full-text tier) and share the main database's schema. Language codes are validated and
161
- resolved server-side; user SQL can never contain ATTACH itself. The response records what
162
- was attached (dump names included), and flags date mismatches between editions.
153
+ full-text index) and share the main database's schema. You pass language codes, not paths,
154
+ and the SQL you write cannot attach anything itself. The response lists what was attached,
155
+ including each edition's dump name, and flags it when the dates differ.
163
156
 
164
- To the best of our knowledge no other system offers version-pinned, cross-edition SQL over
165
- both metadata **and** article text, fully offline.
157
+ This runs entirely offline against pinned dump versions, so a cross-edition comparison can
158
+ be re-run later and produce the same numbers.
166
159
 
167
160
  ## 6. Large results, explicit sets, and reproducibility
168
161
 
@@ -182,26 +175,34 @@ extract_corpus(titles: ["東京物語", "羅生門", ...], content: "summary",
182
175
  redirect hop, and reports unmatched titles in `not_found` — closing the loop
183
176
  *SQL decides the set → the tool materializes it → the LLM reads it*.
184
177
 
185
- ## 7. The alias discovery loop
186
-
187
- Section headings vary ("Plot" vs "Synopsis"; 「あらすじ」 vs 「ストーリー」). wp2txt ships
188
- no per-language dictionaries. Instead, agents discover aliases from the dump itself:
189
-
190
- 1. `section_stats` — find the actual headings used in a scope
191
- 2. LLM proposes synonym groups
192
- 3. `section_cooccurrence` verify mechanically (true synonyms almost never co-occur in
193
- the same article; a high co-occurrence ratio is evidence *against* the hypothesis)
194
- 4. `save_alias_set` persist the verified groups, re-checked server-side, and recorded
195
- in every extraction that uses them
196
-
197
- The bundled `discover_aliases` MCP prompt walks any agent through this protocol.
198
-
199
- ## 8. Honest limitations
200
-
201
- - Queries operate on the **cleaned-text space**: content replaced by markers
202
- (`[MATH]`, `[CODE]`, `[TABLE]`, …) is not searchable.
203
- - Trigram languages (ja/zh/ko) cannot match queries shorter than 3 characters;
204
- word-based languages have no stemming (`run` ≠ `running`). Both are deliberate:
205
- exact counts and absence claims require predictable matching.
206
- - Categories and links come from the dump itself, as written by editors they inherit
207
- Wikipedia's own inconsistencies, which is precisely what makes them worth studying.
178
+ ## 7. Section alias sets
179
+
180
+ Section headings vary by article and by language ("Plot" vs "Synopsis"; 「あらすじ」 vs
181
+ 「ストーリー」), and wp2txt ships no per-language dictionaries. Three tools manage named
182
+ groups of equivalent headings instead:
183
+
184
+ - `section_stats` lists the headings actually used in a scope, with counts.
185
+ - `section_cooccurrence` reports how often two headings appear in the same article.
186
+ Headings that mean the same thing rarely co-occur, so a high ratio is evidence that
187
+ they are *different* sections (「概要」 and 「あらすじ」 co-occur often not synonyms).
188
+ - `save_alias_set` stores a named group. The group is re-checked against co-occurrence
189
+ before saving; a failing group is not stored and the call returns `saved: false`
190
+ rather than an error. Pass `force` to override, and use `list_alias_sets` to see
191
+ what is stored.
192
+
193
+ Queries then accept `alias_set: "name"` in place of a heading list, and extractions
194
+ record the group's exact contents in their `.meta.json`. The bundled `discover_aliases`
195
+ prompt walks an LLM client through building and saving a set.
196
+
197
+ ## 8. Limitations to keep in mind
198
+
199
+ - Searches run over the **cleaned text**: content that extraction replaces with a marker
200
+ (`[MATH]`, `[CODE]`, `[TABLE]`, …) cannot be matched. A count is a count of the cleaned
201
+ text, not of the raw wikitext.
202
+ - Japanese, Chinese, and Korean indexes cannot match queries shorter than 3 characters.
203
+ Word-based languages match exact forms only — `run` does not find `running`. Plan your
204
+ search terms accordingly, especially when you intend to report a zero result.
205
+ - Categories and interlanguage links are read from the dump as editors wrote them.
206
+ Categories added by a template rather than written in the article text are not visible
207
+ to wp2txt, which can make a category look much smaller than it is on the website —
208
+ check against the article text if a count looks wrong.
data/lib/wp2txt/corpus.rb CHANGED
@@ -482,6 +482,31 @@ module Wp2txt
482
482
  SQL_ROW_LIMIT = 200
483
483
  SQL_CELL_LIMIT = 2000
484
484
  SQL_TIMEOUT_SECONDS = 30
485
+
486
+ # Grace added to the child's own CPU limit: the parent's IO.select deadline
487
+ # should normally fire first; this is the fallback for when it cannot.
488
+ SQL_CHILD_CPU_GRACE = 5
489
+
490
+ # Self-imposed deadline for a query child, applied inside the fork.
491
+ #
492
+ # The parent kills the child on timeout, but a child ORPHANED by the
493
+ # parent's death (interrupted test run, closed terminal, crashed server)
494
+ # would otherwise spin forever: sqlite3 holds the GVL inside sqlite3_step,
495
+ # so Ruby's deferred signal handling never reaches a safe point and even
496
+ # SIGTERM is ignored — only SIGKILL or the kernel can stop it. A CPU
497
+ # rlimit is enforced by the kernel regardless of the GVL (SIGXCPU at the
498
+ # soft limit, SIGKILL at the hard one), so the child always dies on its own.
499
+ def self.apply_child_cpu_limit(timeout)
500
+ return unless Process.respond_to?(:setrlimit) && defined?(Process::RLIMIT_CPU)
501
+
502
+ seconds = timeout.to_f.ceil
503
+ Process.setrlimit(Process::RLIMIT_CPU,
504
+ seconds + SQL_CHILD_CPU_GRACE,
505
+ seconds + (SQL_CHILD_CPU_GRACE * 2))
506
+ rescue StandardError
507
+ # A platform without CPU rlimits keeps the previous behaviour (parent-only kill)
508
+ nil
509
+ end
485
510
  SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
486
511
 
487
512
  # File-output mode (query_sql output_path:): hard row cap and per-cell
@@ -656,6 +681,7 @@ module Wp2txt
656
681
  fts_path = fts.db_path
657
682
  reader_io, writer_io = IO.pipe
658
683
  pid = Process.fork do
684
+ self.class.apply_child_cpu_limit(timeout)
659
685
  reader_io.close
660
686
  outcome = begin
661
687
  db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
@@ -797,6 +823,7 @@ module Wp2txt
797
823
  fts_path = fts.db_path
798
824
  reader_io, writer_io = IO.pipe
799
825
  pid = Process.fork do
826
+ self.class.apply_child_cpu_limit(timeout)
800
827
  reader_io.close
801
828
  outcome = begin
802
829
  db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
data/lib/wp2txt/regex.rb CHANGED
@@ -210,6 +210,13 @@ module Wp2txt
210
210
  REMOVE_HR_REGEX = Regexp.new('^\s*\-{4,}\s*$')
211
211
  MAKE_REFERENCE_REGEX_A = Regexp.new('<br ?\/>', Regexp::IGNORECASE)
212
212
  MAKE_REFERENCE_REGEX_B = Regexp.new('<ref[^>]*\/>', Regexp::IGNORECASE)
213
+ # A reference carrying no content adds nothing in either mode
214
+ MAKE_REFERENCE_REGEX_EMPTY = Regexp.new('<ref[^>]*>\s*<\/ref>', Regexp::MULTILINE | Regexp::IGNORECASE)
215
+ # One whole reference span, used to normalize newlines inside it.
216
+ # The span must not contain another opening <ref: an unclosed <ref> would
217
+ # otherwise pair with a later </ref> and swallow the paragraphs in between.
218
+ MAKE_REFERENCE_REGEX_SPAN = Regexp.new('<ref[^>]*>(?:(?!<ref[\s>]).)*?<\/ref>',
219
+ Regexp::MULTILINE | Regexp::IGNORECASE)
213
220
  MAKE_REFERENCE_REGEX_C = Regexp.new('<ref[^>]*>', Regexp::IGNORECASE)
214
221
  MAKE_REFERENCE_REGEX_D = Regexp.new('<\/ref>', Regexp::IGNORECASE)
215
222
  FORMAT_REF_REGEX = Regexp.new('\[ref\](.*?)\[\/ref\]', Regexp::MULTILINE)
@@ -308,6 +308,14 @@ module Wp2txt
308
308
  result = +str.to_s
309
309
  result.gsub!(MAKE_REFERENCE_REGEX_A, "\n")
310
310
  result.gsub!(MAKE_REFERENCE_REGEX_B, "")
311
+ # Element splitting (Article#parse) breaks paragraphs at newlines, so a
312
+ # reference written across lines would land in separate elements and its
313
+ # [ref]/[/ref] pair would never be visible to remove_ref at the same time.
314
+ # Drop empty references outright, and flatten the rest onto one line so
315
+ # multi-line references behave exactly like single-line ones (this is also
316
+ # what makes --extract-citations work on multi-line cite templates).
317
+ result.gsub!(MAKE_REFERENCE_REGEX_EMPTY, "")
318
+ result.gsub!(MAKE_REFERENCE_REGEX_SPAN) { |span| span.gsub(/\s*\n\s*/, " ") }
311
319
  result.gsub!(MAKE_REFERENCE_REGEX_C, "[ref]")
312
320
  result.gsub!(MAKE_REFERENCE_REGEX_D, "[/ref]")
313
321
  result
data/lib/wp2txt/utils.rb CHANGED
@@ -415,11 +415,24 @@ module Wp2txt
415
415
  result.strip
416
416
  end
417
417
 
418
+ # Reference markers are already in [ref] form when external links are
419
+ # processed, and the scanner would strip their brackets (making remove_ref
420
+ # fail). Hide them behind placeholders for the duration of the scan.
421
+ # NOTE: do not special-case them inside the block instead — returning
422
+ # "[ref]" from the block makes process_nested_single_pass re-detect the same
423
+ # spot forever, burning MAX_NESTING_ITERATIONS and leaving the whole string
424
+ # unprocessed (measured: 478x slower, no links processed).
425
+ REF_OPEN_PLACEHOLDER = "«REFOPEN»"
426
+ REF_CLOSE_PLACEHOLDER = "«REFCLOSE»"
427
+
418
428
  def process_external_links(str)
419
429
  # Early exit if no external links present
420
430
  return str unless str.include?("[")
421
431
 
422
- process_nested_single_pass(str, "[", "]") do |contents|
432
+ protected_str = str.gsub("[ref]", REF_OPEN_PLACEHOLDER)
433
+ .gsub("[/ref]", REF_CLOSE_PLACEHOLDER)
434
+
435
+ processed = process_nested_single_pass(protected_str, "[", "]") do |contents|
423
436
  if /\A\s.+\s\z/ =~ contents
424
437
  " (#{contents.strip}) "
425
438
  else
@@ -432,6 +445,9 @@ module Wp2txt
432
445
  end
433
446
  end
434
447
  end
448
+
449
+ processed.gsub(REF_OPEN_PLACEHOLDER, "[ref]")
450
+ .gsub(REF_CLOSE_PLACEHOLDER, "[/ref]")
435
451
  end
436
452
 
437
453
  #################### template processing ####################
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wp2txt
4
- VERSION = "2.3.0"
4
+ VERSION = "2.3.2"
5
5
  end
data/spec/corpus_spec.rb CHANGED
@@ -199,6 +199,31 @@ RSpec.describe Wp2txt::Corpus do
199
199
  expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 5
200
200
  end
201
201
 
202
+ it "gives the query child its own kernel-enforced CPU limit" do
203
+ # A child orphaned by the parent's death (interrupted test run, closed
204
+ # terminal) must still die on its own: sqlite3 holds the GVL inside
205
+ # sqlite3_step, so signals are never processed and only the kernel can
206
+ # stop it. Verified here by reading the limit the child actually gets.
207
+ skip "no CPU rlimits on this platform" unless Process.respond_to?(:setrlimit)
208
+
209
+ reader, writer = IO.pipe
210
+ pid = Process.fork do
211
+ reader.close
212
+ Wp2txt::Corpus.apply_child_cpu_limit(30)
213
+ writer.puts(Process.getrlimit(Process::RLIMIT_CPU).inspect)
214
+ writer.close
215
+ exit!(0)
216
+ end
217
+ writer.close
218
+ limits = reader.read
219
+ reader.close
220
+ Process.waitpid(pid)
221
+
222
+ # 30s query timeout => soft 35s / hard 40s of CPU time. Both exceed the
223
+ # parent's own wall-clock deadline, so no legitimate query is affected.
224
+ expect(limits.strip).to eq("[35, 40]")
225
+ end
226
+
202
227
  it "keeps serving queries after a timeout" do
203
228
  expect do
204
229
  @corpus.query_sql("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c", timeout: 1)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The MCP tool surface and its public documentation drift apart easily
6
+ # (the 2.2.0 README shipped with a tool table missing four tools). This spec
7
+ # pins them together: every tool defined in bin/wp2txt-mcp must appear in the
8
+ # docs/INDEXES.md tool table, and the table must not list phantom tools.
9
+ RSpec.describe "documentation surface sync" do
10
+ repo_root = File.expand_path("..", __dir__)
11
+
12
+ define_method(:defined_tools) do
13
+ src = File.read(File.join(repo_root, "bin", "wp2txt-mcp"))
14
+ src.scan(/server\.define_tool\(\s*name:\s*"([a-z_]+)"/).flatten
15
+ end
16
+
17
+ define_method(:documented_tools) do
18
+ doc = File.read(File.join(repo_root, "docs", "INDEXES.md"))
19
+ table = doc[/^### Tools\n(.*?)\n\n/m, 1]
20
+ raise "Tools table not found in docs/INDEXES.md" unless table
21
+
22
+ # Tool names live in the first column only (the purpose column may
23
+ # backtick argument names like `attach`)
24
+ table.lines.filter_map { |line| line.split("|")[1] }
25
+ .flat_map { |cell| cell.scan(/`([a-z_]+)`/).flatten }
26
+ .uniq
27
+ end
28
+
29
+ it "defines a non-trivial number of MCP tools" do
30
+ expect(defined_tools.size).to be >= 15
31
+ end
32
+
33
+ it "documents every MCP tool in docs/INDEXES.md, with no phantom entries" do
34
+ missing = defined_tools - documented_tools
35
+ phantom = documented_tools - defined_tools
36
+ expect(missing).to be_empty, "tools not documented in docs/INDEXES.md: #{missing.join(', ')}"
37
+ expect(phantom).to be_empty, "documented tools that do not exist: #{phantom.join(', ')}"
38
+ end
39
+
40
+ # Tripwire: tracked files must not contain tokens listed in .private-doc-tokens,
41
+ # an untracked, machine-local file (one substring per line; # starts a comment).
42
+ # The file exists only on machines that maintain such a list; everywhere else
43
+ # (CI, other contributors) this example skips — loudly, so a silently dead
44
+ # check cannot be mistaken for a passing one.
45
+ it "keeps machine-local private tokens out of tracked files" do
46
+ token_file = File.join(repo_root, ".private-doc-tokens")
47
+ skip "SKIPPED: no .private-doc-tokens on this machine — tripwire not checked" unless File.exist?(token_file)
48
+
49
+ tokens = File.readlines(token_file, encoding: "UTF-8")
50
+ .map(&:strip).reject { |t| t.empty? || t.start_with?("#") }
51
+ tracked = `git -C #{repo_root} ls-files -z`.split("\x0")
52
+ hits = tracked.flat_map do |f|
53
+ path = File.join(repo_root, f)
54
+ next [] unless File.file?(path)
55
+
56
+ content = File.read(path, encoding: "BINARY")
57
+ tokens.filter_map { |t| "#{f}: #{t}" if content.include?(t.b) }
58
+ end
59
+ expect(hits).to be_empty, "private tokens found in tracked files:\n #{hits.join("\n ")}"
60
+ end
61
+ end
data/spec/spec_helper.rb CHANGED
@@ -59,6 +59,28 @@ RSpec.configure do |config|
59
59
  mocks.verify_partial_doubles = true
60
60
  end
61
61
 
62
+ # Per-example wall-clock guard. The suite deliberately exercises runaway
63
+ # queries and forked workers; an interrupted or wedged example must not turn
64
+ # into a process spinning at 100% CPU for days (observed 2026-07-24).
65
+ # Slowest legitimate example is ~14s, so 120s is pure headroom. Override with
66
+ # WP2TXT_SPEC_TIMEOUT=0 to disable, or tag an example :no_timeout.
67
+ spec_timeout = (ENV["WP2TXT_SPEC_TIMEOUT"] || 120).to_i
68
+ if spec_timeout.positive?
69
+ require "timeout"
70
+ config.around(:each) do |example|
71
+ if example.metadata[:no_timeout]
72
+ example.run
73
+ else
74
+ begin
75
+ Timeout.timeout(spec_timeout) { example.run }
76
+ rescue Timeout::Error
77
+ raise "example exceeded the #{spec_timeout}s spec timeout (possible hang; " \
78
+ "set WP2TXT_SPEC_TIMEOUT=0 to disable this guard)"
79
+ end
80
+ end
81
+ end
82
+ end
83
+
62
84
  config.shared_context_metadata_behavior = :apply_to_host_groups
63
85
  config.filter_run_when_matching :focus
64
86
  config.example_status_persistence_file_path = "spec/examples.txt"