wp2txt 2.3.0 → 2.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 076aa3a7681c0e0e6e13948e5c66f6f9e15a0307507a944a78f29c0e7856eea0
4
- data.tar.gz: f8f2d3a4efc82d1922f9ca2764b23bb348aa924e752439805ab61683ff66a737
3
+ metadata.gz: 4062c47480d0801e7a07d8e3bec7c65be8efeeda2eae30d398f9872810d321cd
4
+ data.tar.gz: 25bb3aa8a4d5baefc4616a8a4b754ec63e92d3e0b86b22ccc0ead2929a39739e
5
5
  SHA512:
6
- metadata.gz: ae377878c20dde5a376d96efec19f84a74a20e66dd8f45ff5d41c438f519ced7cf5d4e6a6102ceee5c4b59333f0b1ca42167cf5e178abf803de868855b4efa6c
7
- data.tar.gz: c23cb4598a7afeda092d75688be12e9e777874a4b9da289aeb1d97123b5cc64b3b56337722290fcc4352fde9031a856d4ba81393055ef8596fe8818d51e258ac
6
+ metadata.gz: ff61afbb989613e286af8bea784e4494c8f71b0dd52d24d3d038e72c5d77f9196d899039d9ff8c5e4b16461c044670d8cca6bc3feda55aa8cdbc31cbc30e012b
7
+ data.tar.gz: ddffdfb2452a3710d527bc7deeae52818cb9c2b4dedd433b2c7ecb06db74e6e389edf127976567a3269583531acf0aabc2ea9058d7422b5b675de9bae2e227fa
data/.dockerignore CHANGED
@@ -6,11 +6,12 @@ pkg
6
6
  spec
7
7
  coverage
8
8
  tmp
9
+ .bundle
10
+ research-notes
9
11
  benchmark_results
10
12
  data/output_samples
11
13
  scripts
12
14
  .dockerignore
13
- .gitignore
14
15
  .solargraph.yml
15
16
  .rubocop.yml
16
17
  Gemfile.lock
data/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.1] - 2026-08-12
9
+
10
+ - **Container images no longer carry private files**: the image is built from the working tree with `git init && git add -A`, but the build context excluded `.gitignore`, so files ignored locally were tracked inside the container and shipped in the image — 2.3.0's images contained the maintainer's private research notes under `/wp2txt/research-notes`. The context now keeps `.gitignore` (so the container's file list matches a local `gem build`), explicitly excludes `research-notes` and `.bundle`, and the throwaway `.git` (a blob copy of every added file) is deleted after the build. `rake check_image` builds and inspects an image for private paths, and `rake push` refuses to publish without that check. The published gems were never affected; 2.3.0 images have been withdrawn
11
+ - **Docker Hub retired**: images are published to GitHub Container Registry only (`ghcr.io/yohasebe/wp2txt`). The Docker Hub repository is no longer updated
12
+ - **Fix: multi-line `<ref>` references broken by element splitting — also fixes `--extract-citations` on multi-line cite templates**: `Article#parse` splits elements at newlines, so any reference written across lines (very common for multi-line `{{cite …}}` templates) had its `[ref]` and `[/ref]` markers land in separate elements, invisible to `remove_ref`. The markers and raw reference markup leaked into the output, and — more importantly — `--extract-citations` silently never fired for multi-line cite templates, producing inconsistent output versus their single-line equivalents. `make_reference` now drops empty references outright and flattens the rest onto a single line before element splitting, so multi-line references behave exactly like single-line ones. Ordinary paragraph breaks outside references are unaffected
13
+ - **Fix: `[ref]` markers destroyed by external-link processing**: `process_external_links` stripped the brackets of the `[ref]`/`[/ref]` markers produced by `make_reference` (their contents took the single-word branch), so `remove_ref` could no longer find them and tag names plus reference bodies leaked into extracted text — even with `--ref`, the kept markers came out broken. The markers are now hidden behind placeholders for the duration of the bracket scan and restored afterwards. Downstream corpora no longer contain `ref…/ref` residue, which broke tokenization/sentence splitting and mixed bibliographic text into body prose
14
+ - **Runaway-query hardening**: a `query_sql` child process now sets its own kernel-enforced CPU limit (`RLIMIT_CPU`, the query timeout plus a small grace) in addition to the parent's wall-clock kill. Previously a child orphaned by the parent's death — an interrupted test run, a closed terminal, a crashed server — kept executing forever: sqlite3 holds the GVL inside `sqlite3_step`, so Ruby never reaches a signal-safe point and even SIGTERM is ignored. Observed in the wild as two processes spinning at 99% CPU for over five days. The CPU limit is strictly more permissive than the existing wall-clock deadline, so no query that would otherwise succeed is affected
15
+ - **Test-suite hang guard**: each example now runs under a wall-clock timeout (120s default; `WP2TXT_SPEC_TIMEOUT=0` disables it, the `:no_timeout` tag exempts an example). The suite deliberately exercises runaway queries, so a wedged example must fail rather than spin
16
+
8
17
  ## [2.3.0] - 2026-07-24
9
18
 
10
19
  - **Documentation split**: README now focuses on text extraction; the research layer (indexes, exhaustive queries, full-text search, langlinks, cross-language SQL, MCP server) is documented in the new [Research Infrastructure Guide](docs/RESEARCH.md), including the complete MCP tool table
data/DEVELOPMENT.md CHANGED
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
395
395
  Build and push Docker images:
396
396
 
397
397
  ```bash
398
- rake push # Builds multi-arch and pushes to Docker Hub
398
+ rake check_image # Builds the image locally and verifies it carries no private files
399
+ rake push # Verifies, then builds multi-arch and pushes to GHCR
399
400
  ```
400
401
 
401
402
  ## Release Process
data/DEVELOPMENT_ja.md CHANGED
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
395
395
  Dockerイメージのビルドとプッシュ:
396
396
 
397
397
  ```bash
398
- rake push # マルチアーキテクチャでビルドしDocker Hubにプッシュ
398
+ rake check_image # ローカルでイメージをビルドし、私的ファイルの混入がないか検証
399
+ rake push # 検証したうえでマルチアーキテクチャでビルドしGHCRにプッシュ
399
400
  ```
400
401
 
401
402
  ## リリースプロセス
data/Dockerfile CHANGED
@@ -7,7 +7,12 @@ WORKDIR /wp2txt
7
7
  COPY . ./
8
8
  RUN rm -f Gemfile.lock
9
9
 
10
- # Install dependencies (git is required by gemspec's `git ls-files`)
10
+ # Install dependencies (git is required by gemspec's `git ls-files`).
11
+ # The repository's .gitignore is copied in deliberately: `git add -A` must
12
+ # honour it so the file list here matches a local `gem build` — without it,
13
+ # ignored material (private notes, scratch files) would land in the image.
14
+ # The throwaway .git is removed afterwards: it holds a blob copy of every
15
+ # added file and is dead weight in the published image.
11
16
  RUN apk update && \
12
17
  apk upgrade && \
13
18
  apk add --no-cache \
@@ -17,6 +22,7 @@ RUN apk update && \
17
22
  build-base curl-dev wget && \
18
23
  git init && git add -A && \
19
24
  bundle install -j4 && \
25
+ rm -rf /wp2txt/.git && \
20
26
  apk del .build-packages
21
27
 
22
28
  # lbzip2 is not available as an Alpine package; build from source
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
 
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
+ web/API アクセスと異なり、これらのクエリは全記事を走査します(`0件` は当該ダンプ版に対する
352
+ 検証可能な不在の言明になります)。抽出結果には dump 版とクエリを記録した `.meta.json`
353
+ サイドカーが付き、再現可能です。
354
+
355
+ **→ 詳細は [Research Infrastructure Guide](docs/RESEARCH.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; 2.3.0's images shipped research-notes/ this way.
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" },
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.1"
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,39 @@
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/RESEARCH.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", "RESEARCH.md"))
19
+ table = doc[/^### Tools\n(.*?)\n\n/m, 1]
20
+ raise "Tools table not found in docs/RESEARCH.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/RESEARCH.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/RESEARCH.md: #{missing.join(', ')}"
37
+ expect(phantom).to be_empty, "documented tools that do not exist: #{phantom.join(', ')}"
38
+ end
39
+ 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"
data/spec/utils_spec.rb CHANGED
@@ -59,6 +59,135 @@ RSpec.describe "Wp2txt Utils" do
59
59
  end
60
60
  end
61
61
 
62
+ # Regression: process_external_links used to strip the brackets of the
63
+ # [ref]/[/ref] markers (contents "ref" / "/ref" take the parts.size == 1
64
+ # branch), so remove_ref could no longer find them and the tag names plus
65
+ # reference body leaked into the extracted text. These tests exercise the
66
+ # composed make_reference -> format_wiki path, since testing remove_ref in
67
+ # isolation passes even with the bug.
68
+ describe "reference markers through format_wiki" do
69
+ it "removes <ref>...</ref> including the body" do
70
+ input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
71
+ result = format_wiki(make_reference(input))
72
+ expect(result).to eq "Paris was founded. The city grew."
73
+ end
74
+
75
+ it "removes named <ref name=\"x\">...</ref> including the body" do
76
+ input = "Fine dining.<ref name=\"lemonde\">Le Monde, 2 February 2015</ref> Paris has."
77
+ result = format_wiki(make_reference(input))
78
+ expect(result).to eq "Fine dining. Paris has."
79
+ end
80
+
81
+ it "removes self-closing <ref name=\"x\"/>" do
82
+ input = "An asteroid,<ref name=\"x\"/> and a building."
83
+ result = format_wiki(make_reference(input))
84
+ expect(result).to eq "An asteroid, and a building."
85
+ end
86
+
87
+ it "removes consecutive <ref>A</ref><ref>B</ref> without joining tag names" do
88
+ input = "Text<ref>A</ref><ref>B</ref> more."
89
+ result = format_wiki(make_reference(input))
90
+ expect(result).to eq "Text more."
91
+ end
92
+
93
+ it "keeps [ref]...[/ref] markers when config[:ref] is true" do
94
+ input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
95
+ result = format_wiki(make_reference(input), ref: true)
96
+ expect(result).to eq "Paris was founded.[ref]Patrick Boucheron, France in the World (2019) pp 81-86.[/ref] The city grew."
97
+ end
98
+
99
+ # Guards against the rejected fix of returning "[ref]" from the scanner
100
+ # block: that makes process_nested_single_pass re-detect the same spot
101
+ # until MAX_NESTING_ITERATIONS, leaving external links unprocessed.
102
+ it "still processes external links outside [ref] markers" do
103
+ input = "Claim.<ref>See [http://example.com the site] for detail.</ref> Next [http://foo.com Foo] end."
104
+ expect(format_wiki(make_reference(input))).to eq "Claim. Next Foo end."
105
+ expect(format_wiki(make_reference(input), ref: true)).to eq "Claim.[ref]See the site for detail.[/ref] Next Foo end."
106
+ end
107
+ end
108
+
109
+ # Regression: element splitting (Article#parse) breaks paragraphs at
110
+ # newlines, so a reference written across lines landed in separate elements
111
+ # with [ref] and [/ref] never visible to remove_ref at the same time. This
112
+ # also meant --extract-citations never fired for multi-line cite templates.
113
+ # These tests go through Article.new -> format_wiki per element, since
114
+ # passing a string to format_wiki directly skips element splitting and does
115
+ # not reproduce the bug.
116
+ describe "multi-line references through element splitting" do
117
+ def render_elements(wikitext, config = {})
118
+ Wp2txt::Article.new(wikitext).elements.map { |e| format_wiki(e[1], config) }.join
119
+ end
120
+
121
+ it "removes an empty reference spanning a blank line" do
122
+ result = render_elements("...in the Super League.<ref>\n\n</ref> In 2006, Catalans Dragons became...")
123
+ expect(result).not_to include("[ref]")
124
+ expect(result).not_to include("[/ref]")
125
+ expect(result).to include("Super League. In 2006,")
126
+ end
127
+
128
+ it "removes a named empty reference spanning a blank line" do
129
+ result = render_elements("Claim.<ref name=\"x\">\n\n</ref> Next.")
130
+ expect(result).not_to include("[ref]")
131
+ expect(result).not_to include("[/ref]")
132
+ expect(result).to include("Claim. Next.")
133
+ end
134
+
135
+ it "removes a non-empty reference spanning a blank line" do
136
+ result = render_elements("Claim.<ref>Author\n\nPublisher</ref> Next.")
137
+ expect(result).not_to include("[ref]")
138
+ expect(result).not_to include("[/ref]")
139
+ expect(result).to include("Claim. Next.")
140
+ end
141
+
142
+ it "removes a multi-line cite template reference (with and without leading space)" do
143
+ ["Claim.<ref> {{cite book\n |title=Foo\n |year=2019}}</ref> Next.",
144
+ "Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next."].each do |input|
145
+ result = render_elements(input)
146
+ expect(result).not_to include("[ref]")
147
+ expect(result).not_to include("[/ref]")
148
+ expect(result).not_to include("cite book")
149
+ expect(result).to include("Claim. Next.")
150
+ end
151
+ end
152
+
153
+ # The core of this fix: before, only the multi-line form leaked raw
154
+ # markup, so --extract-citations silently did nothing for it.
155
+ it "extracts citations identically from single-line and multi-line cite templates" do
156
+ config = { extract_citations: true, ref: true }
157
+ single = render_elements("Claim.<ref>{{cite book|title=Foo|year=2019}}</ref> Next.", config)
158
+ multi = render_elements("Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next.", config)
159
+ expect(multi).to eq single
160
+ expect(single).to include("[ref]\"Foo\". 2019.[/ref]")
161
+ end
162
+
163
+ # Control: newlines outside references must still split paragraphs.
164
+ it "still splits ordinary paragraph boundaries at blank lines" do
165
+ elements = Wp2txt::Article.new("First para.\n\nSecond para.").elements
166
+ paragraphs = elements.select { |e| e[0] == :mw_paragraph }
167
+ expect(paragraphs.size).to eq 2
168
+ expect(paragraphs[0][1]).to include("First para.")
169
+ expect(paragraphs[1][1]).to include("Second para.")
170
+ end
171
+
172
+ # An unclosed <ref> must not pair with a later </ref> across paragraphs;
173
+ # the body text stays (a floating [ref] is the pre-existing behavior for
174
+ # this malformed markup).
175
+ it "does not swallow paragraphs when a <ref> is left unclosed" do
176
+ wikitext = "First para with <ref>unclosed reference.\n\nSecond para here.\n\n" \
177
+ "Third para with <ref>closed</ref> end."
178
+ result = render_elements(wikitext)
179
+ expect(result).to include("Second para here.")
180
+ expect(result).to include("First para with")
181
+ end
182
+
183
+ it "handles a multi-line reference with a group attribute" do
184
+ result = render_elements("Claim.<ref group=\"note\">Author\nTitle 2019</ref> Next.")
185
+ expect(result).not_to include("[ref]")
186
+ expect(result).not_to include("[/ref]")
187
+ expect(result).to include("Claim. Next.")
188
+ end
189
+ end
190
+
62
191
  describe "remove_table" do
63
192
  it "removes table formated parts" do
64
193
  str_before = "{| ... \n{| ... \n ...|}\n ...|}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wp2txt
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -276,6 +276,7 @@ files:
276
276
  - spec/config_spec.rb
277
277
  - spec/constants_spec.rb
278
278
  - spec/corpus_spec.rb
279
+ - spec/docs_sync_spec.rb
279
280
  - spec/file_utils_spec.rb
280
281
  - spec/fixtures/samples.rb
281
282
  - spec/formatter_sections_spec.rb
@@ -341,6 +342,7 @@ test_files:
341
342
  - spec/config_spec.rb
342
343
  - spec/constants_spec.rb
343
344
  - spec/corpus_spec.rb
345
+ - spec/docs_sync_spec.rb
344
346
  - spec/file_utils_spec.rb
345
347
  - spec/fixtures/samples.rb
346
348
  - spec/formatter_sections_spec.rb