asciidoc-pubkit 0.1.0 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c17eb36b6d72e3d59299a979d643ff36206c881f3515e33d4ed88a3fb37a711
4
- data.tar.gz: 7ecfa1dc6e556d15b35af4e942919f6700d7c5fae46fc8355fc43467ef31f2e3
3
+ metadata.gz: de07af1758fdba08a7099b9e66f63b14084d0dad6cba8437498f0ddd0577b425
4
+ data.tar.gz: 8474d883c10c23f1221ff85cbd023d781c84f93dd55436a542c0d304c0a8f424
5
5
  SHA512:
6
- metadata.gz: 2955fc8990b297e8280080d1cd1e8f3d8f72e976e1d0e2c23845d9543328115f7164111ae1bb9323b24c72c31762e36429e399049ac666d10014aef629b803e1
7
- data.tar.gz: 412ffe4c96f1d00c5f00a8ffc47e0f6f9fa92d5e5fd3fa7af88c911fb51169cfc7e206543fdaac6caaabf41cf7c0c000fcc587912e60c58358b134a1ce1b47c7
6
+ metadata.gz: 6983524a7967131b469360bebd417125d7eea14cdf8054d14c928940528d685ee50fd0c402ca354073a55e643217ed931ddda3ca3e402d252d02e4030b7b3a9e
7
+ data.tar.gz: 8e8111fea8d32dfb1449c48cfc8d8f2df22e58dec14a44a55c6dcb6596a6bd3e87aa86fbd596cfc332a6ca49c9d1452f5dd34b23126137d9359a081585e1ae56
data/CHANGELOG.md CHANGED
@@ -1,6 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0Unreleased
3
+ ## 0.1.2Released
4
+
5
+ - Package default Japanese review rules as a validated UTF-8 YAML file.
6
+ - Support custom rule files through `review scan --rules` and `review.rules`.
7
+ - Save resolved rules in sessions for reproducible prompts and verification.
8
+ - Add concept, distinction, and negative meaning review candidates.
9
+ - Add a Make target for running a local checkout with arbitrary CLI arguments.
10
+ - Require a new review session when upgrading from earlier tool versions.
11
+
12
+ ## 0.1.1 — Released
13
+
14
+ - Add contextual phrases, compound nouns, and potential and causative verb forms.
15
+ - Add requested abstract nouns, degree adjectives, and vague predicates.
16
+ - Use MeCab with UTF-8 IPADIC by default to match inflected predicates and adjectives.
17
+ - Preserve original surfaces, dictionary forms, and negative auxiliary information.
18
+ - Record analyzer and dictionary fingerprints in review sessions.
19
+ - Add an explicit literal mode for environments without MeCab.
20
+ - Reject incompatible dictionaries and unavailable analyzers with setup guidance.
21
+ - Require a new review session when upgrading from 0.1.0.
22
+
23
+ ## 0.1.0 — Released
4
24
 
5
25
  - Add AsciiDoc running-prose extraction with checked source locations.
6
26
  - Add Japanese review candidates and configurable terminology checks.
data/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ ![](https://img.shields.io/badge/status-Work%20In%20Progress-8A2BE2)
2
+ ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/cybergarage/asciidoc-pubkit)
3
+ [![Gem Version](https://img.shields.io/gem/v/asciidoc-pubkit.svg)](https://rubygems.org/gems/asciidoc-pubkit)
4
+ [![Build Status](https://github.com/cybergarage/asciidoc-pubkit/actions/workflows/test.yml/badge.svg)](https://github.com/cybergarage/asciidoc-pubkit/actions/workflows/test.yml)
5
+
1
6
  # asciidoc-pubkit
2
7
 
3
8
  A toolkit for authoring, reviewing, and publishing AsciiDoc books.
@@ -14,12 +19,15 @@ rewrite manuscripts, or publish books. EPUB, image, and book scaffolding command
14
19
  are planned extensions, not available features.
15
20
 
16
21
  Ruby 3.2 or later is required. Asciidoctor is installed as a gem dependency.
17
- Node.js, textlint, and a morphological analyzer are not required in this release.
18
- The built-in rules use literal phrase matching and simple sentence heuristics.
22
+ Starting with version 0.1.1, the default review backend requires the external MeCab
23
+ command and a UTF-8 IPADIC dictionary. Node.js and textlint are not required.
24
+ Explicit `--tokenizer literal` mode provides limited phrase matching without MeCab.
25
+
26
+ Version 0.1.1 includes morphological analysis. Version 0.1.0 uses literal matching.
19
27
 
20
28
  ## Install from RubyGems
21
29
 
22
- After a release is published on RubyGems, install the CLI with:
30
+ Install the CLI from [RubyGems](https://rubygems.org/gems/asciidoc-pubkit):
23
31
 
24
32
  ```sh
25
33
  gem install asciidoc-pubkit
@@ -28,8 +36,8 @@ asciidoc-pubkit --help
28
36
  ```
29
37
 
30
38
  Ruby 3.2 or later is required. RubyGems installs the required Ruby dependencies;
31
- no repository clone or Node.js installation is needed. If no release is available
32
- on RubyGems yet, use the source installation instructions below.
39
+ no repository clone or Node.js installation is needed. MeCab and IPADIC must be
40
+ installed separately when using version 0.1.1 or later in the default mode.
33
41
 
34
42
  Run the review workflow from your manuscript directory:
35
43
 
@@ -52,7 +60,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
52
60
 
53
61
  ```ruby
54
62
  source 'https://rubygems.org'
55
- gem 'asciidoc-pubkit', '~> 0.1.0'
63
+ gem 'asciidoc-pubkit', '~> 0.1.2'
56
64
  ```
57
65
 
58
66
  Then install dependencies and run the CLI through Bundler:
@@ -74,7 +82,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
74
82
  cd asciidoc-pubkit
75
83
  bundle install
76
84
  gem build asciidoc-pubkit.gemspec
77
- gem install ./asciidoc-pubkit-0.1.0.gem
85
+ gem install ./asciidoc-pubkit-0.1.2.gem
78
86
  asciidoc-pubkit --version
79
87
  ```
80
88
 
@@ -85,6 +93,64 @@ For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
85
93
  paragraphs deliberately contain review candidates; its code block must remain
86
94
  unchanged.
87
95
 
96
+ ## Run from a local checkout
97
+
98
+ Use the `run` Make target to execute the checkout without installing the
99
+ asciidoc-pubkit gem. Ruby dependencies and, for the default tokenizer, MeCab and
100
+ UTF-8 IPADIC must already be installed.
101
+
102
+ ```sh
103
+ make run ARGS="--version"
104
+ make run ARGS="review scan examples/book.adoc"
105
+ make run ARGS='review scan "manuscripts/my book.adoc"'
106
+ ```
107
+
108
+ With no `ARGS`, `make run` displays CLI help. Set `RUBY` to choose another Ruby
109
+ executable. `ARGS` is shell command-line text; quote paths containing spaces and
110
+ use only trusted arguments.
111
+
112
+ To run from a manuscript directory, select the checkout's Makefile with `-f`.
113
+ Relative manuscript paths and output paths remain relative to your current
114
+ working directory:
115
+
116
+ ```sh
117
+ make -f "$HOME/Src/asciidoc-pubkit/Makefile" run ARGS="review scan book.adoc"
118
+ ```
119
+
120
+ Use `ARGS` instead of `make review scan book.adoc`: Make interprets positional
121
+ words as build targets, not CLI arguments. Avoid `make -C` when manuscript paths
122
+ should remain relative to the current directory, because it changes directories.
123
+
124
+ ## Install the morphological analyzer (0.1.1 and later)
125
+
126
+ On macOS with Homebrew:
127
+
128
+ ```sh
129
+ brew install mecab mecab-ipadic
130
+ mecab -D
131
+ ```
132
+
133
+ On Ubuntu or Debian:
134
+
135
+ ```sh
136
+ sudo apt-get update
137
+ sudo apt-get install mecab mecab-ipadic-utf8
138
+ mecab -D
139
+ ```
140
+
141
+ Use a UTF-8 IPADIC dictionary. If the default dictionary is different, configure
142
+ `review.mecab_dictionary` with the IPADIC directory reported by your package
143
+ manager. UniDic and other feature layouts are rejected explicitly. MeCab and
144
+ IPADIC are separately installed dependencies, not bundled inside this gem.
145
+
146
+ The tool checks MeCab availability, dictionary encoding, and feature layout.
147
+ It never silently falls back to literal matching. To deliberately run without
148
+ morphological analysis:
149
+
150
+ ```sh
151
+ asciidoc-pubkit review scan book.adoc --tokenizer literal --output .pubkit/literal-review
152
+ ```
153
+
88
154
  ## Review workflow
89
155
 
90
156
  Run the following commands from the manuscript project directory:
@@ -111,7 +177,7 @@ asciidoc-pubkit review scan book.adoc --attribute edition=print --base-dir .
111
177
 
112
178
  An entrypoint or a standalone chapter can be scanned. Includes and conditionals
113
179
  are processed by Asciidoctor. Match the publishing build's attributes and base
114
- directory to review the intended edition. `--only` selects one included source
180
+ directory to review the intended edition. `--only` selects one included source
115
181
  file while retaining the book's attributes and heading hierarchy. Its path is
116
182
  relative to the current working directory, as are other CLI path arguments.
117
183
 
@@ -190,6 +256,10 @@ The initial release loads one configuration file, not merged book/repository fil
190
256
  review:
191
257
  language: ja
192
258
  style: desu-masu
259
+ tokenizer: mecab
260
+ # Optional overrides (dictionary paths are relative to this file):
261
+ # mecab_command: /opt/homebrew/bin/mecab
262
+ # mecab_dictionary: /opt/homebrew/lib/mecab/dic/ipadic
193
263
  base_dir: .
194
264
  glossary: glossary.yml
195
265
  exclude:
@@ -213,10 +283,13 @@ JavaScript:
213
283
  - Java Script
214
284
  ```
215
285
 
216
- Variants are review candidates, not automatic replacement instructions. `allows`
217
- suppresses exact terms from the built-in phrase rules; it does not disable glossary
218
- checks. Regex patterns are not interpreted in glossary or allow-list entries.
219
- Unknown configuration keys are rejected.
286
+ Variants are review candidates, not automatic replacement instructions. In MeCab
287
+ mode, `allows` can suppress a canonical dictionary form (all its inflections) or
288
+ an exact matched surface (that occurrence's form only). In literal mode it
289
+ suppresses exact dictionary entries. It does not disable glossary checks.
290
+ Regex patterns are not interpreted in glossary or allow-list entries. Unknown
291
+ configuration keys are rejected. A bare `mecab_command` is resolved through PATH;
292
+ use an absolute path for an explicit executable override.
220
293
 
221
294
  ## Rules and coverage
222
295
 
@@ -224,11 +297,44 @@ Unknown configuration keys are rejected.
224
297
  | --- | --- | --- |
225
298
  | `abstract-reference` | hint | Ask what an abstract noun refers to |
226
299
  | `weak-predicate` | hint | Ask whether an operation's purpose or result is clear |
300
+ | `contextual-phrase` | hint | Review referents, assumptions, and qualifications while preserving negation |
227
301
  | `generic-framing` | hint | Review generic introductions and emphasis |
302
+ | `vague-degree` | hint | Ask what depth, level, scope, or comparison is intended |
228
303
  | `repeated-ending` | info | Identify three consecutive sentences with the same detected ending |
229
304
  | `glossary-variant` | warning | Identify project-specific terminology variants |
230
305
  | `style-candidate` | hint | Check selected polite/plain endings against an explicit style |
231
306
 
307
+ MeCab mode matches noun and adjective tokens and dictionary forms of verbs.
308
+ Sahen predicates are matched as a noun followed by the verb for "do"; standalone
309
+ sahen nouns are not treated as verbal predicates. Auxiliary sequences retain
310
+ negation, past tense, passive forms, and progressive forms in the reported surface.
311
+ The added reach predicate is negative-only; the existing handling predicate is
312
+ reviewed in both affirmative and negative forms. Glossary variants and generic
313
+ framing phrases continue to use literal matching. Contextual phrases also use
314
+ literal matching and suppress overlapping morphological candidates. Compound
315
+ nouns are matched across adjacent noun tokens. Selection and narrowing verbs
316
+ include potential forms; predicate surfaces also preserve causative auxiliaries.
317
+
318
+ Morphological candidates include `lemma`, `part_of_speech`, `negative`, and
319
+ `detector` alongside the original `match`, line, and column. Negation detection
320
+ covers common IPADIC negative auxiliaries; it is not full semantic analysis of
321
+ negation scope or double negatives. Unknown tokens are not guessed. Kana/kanji
322
+ variants of the alignment and gathering verbs have explicit canonical mappings;
323
+ other spelling variants are not automatically normalized.
324
+
325
+ The session records the MeCab version and dictionary file hashes. Verification
326
+ reports analyzer changes instead of treating results from different dictionaries
327
+ as directly comparable. Prompt generation uses saved evidence and does not need
328
+ MeCab. Changed rules or dictionary settings require a new scan.
329
+
330
+ Sessions from earlier tool versions are not compatible with 0.1.2. Keep the original baseline for
331
+ an ongoing review and finish it with the original version, or start a new review
332
+ pass in a different directory:
333
+
334
+ ```sh
335
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.1.2
336
+ ```
337
+
232
338
  Severity describes review priority, not proof of an error. There is no AI-authorship
233
339
  score and no requirement to eliminate every match.
234
340
 
@@ -254,7 +360,9 @@ gem build asciidoc-pubkit.gemspec
254
360
  ```
255
361
 
256
362
  The tests exercise include-boundary mapping, inline masking, configuration,
257
- contextual prompts, stale inputs, and protected-content verification.
363
+ contextual prompts, stale inputs, protected-content verification, and real MeCab
364
+ analysis of inflections, negative predicates, Unicode positions, and long lines.
365
+ Install MeCab and UTF-8 IPADIC before running the complete test suite.
258
366
  GitHub Actions is configured for Ruby 3.2, 3.3, 3.4, and 4.0 on Linux.
259
367
 
260
368
  ## License
@@ -262,3 +370,69 @@ GitHub Actions is configured for Ruby 3.2, 3.3, 3.4, and 4.0 on Linux.
262
370
  Copyright 2026 CyberGarage.
263
371
 
264
372
  Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE).
373
+
374
+ ## Customize review rules
375
+
376
+ The UTF-8 YAML file [`data/review-rules.ja.yml`](data/review-rules.ja.yml) is
377
+ included in the gem and loaded by default. Edit that file when running a local
378
+ checkout, or copy it to a project-owned file for custom rules. No Ruby changes
379
+ are required to update candidate terms.
380
+
381
+ ```sh
382
+ asciidoc-pubkit review scan book.adoc --rules ./review-rules.yml
383
+ make run ARGS="review scan book.adoc --rules ./review-rules.yml"
384
+ ```
385
+
386
+ For an installed gem, copy the default file with:
387
+
388
+ ```sh
389
+ ruby -rasciidoc_pubkit -e 'puts File.read(AsciidocPubkit::RuleSet::DEFAULT_PATH)' > review-rules.yml
390
+ ```
391
+
392
+ Alternatively, configure a path in `.asciidoc-pubkit.yml`:
393
+
394
+ ```yaml
395
+ review:
396
+ rules: review-rules.yml
397
+ ```
398
+
399
+ Precedence is `--rules`, then `review.rules`, then the packaged default.
400
+ CLI paths are relative to the current working directory; configuration paths
401
+ are relative to the configuration file. A custom file replaces the entire rule
402
+ set; it is not merged with defaults. Start by copying the standard file.
403
+
404
+ ### Rule file format (schema version 1)
405
+
406
+ All top-level fields below are required. Unknown keys and invalid types are
407
+ rejected. YAML aliases and object tags are not supported.
408
+
409
+ | Field | Format and behavior |
410
+ | --- | --- |
411
+ | `schema_version` | Integer `1` |
412
+ | `terms` | Mapping containing all five categories listed below |
413
+ | `verbs` | Mapping from canonical verb forms to nonempty arrays of MeCab/IPADIC dictionary forms; each dictionary form belongs to only one canonical form |
414
+ | `sahen` | Array of nouns matched with a following `する` verb |
415
+ | `negative_only` | Array of canonical predicates restricted to negative forms; use the noun plus `する` for sahen predicates |
416
+ | `compound_nouns` | Array of terms matched across contiguous noun tokens; each must also appear in `abstract-reference.terms` |
417
+
418
+ Each `terms` category must contain `terms` (an array of unique nonempty strings)
419
+ and `question` (a nonempty review instruction string). The required categories
420
+ are `abstract-reference`, `weak-predicate`, `vague-degree`, `contextual-phrase`,
421
+ and `generic-framing`. Empty term arrays disable that category's literal
422
+ candidates. Empty `verbs`, `sahen`, and `compound_nouns` collections disable their
423
+ respective morphological matchers. Keep `negative_only` consistent with the
424
+ configured predicates.
425
+
426
+ In literal mode, all category term lists use exact phrase matching. In MeCab
427
+ mode, abstract nouns and degree adjectives use dictionary forms, while predicates
428
+ use `verbs` and `sahen`; add a predicate's desired literal surface to
429
+ `weak-predicate.terms` as well if literal mode should detect it. Contextual and
430
+ generic framing phrases use literal matching in both modes. Questions apply to
431
+ both detectors. Glossary, style, repeated-ending checks, inline exclusions, and
432
+ morphological suffix handling remain implemented in Ruby.
433
+
434
+ A scan saves the resolved rule contents and source path in `manifest.json`.
435
+ Prompt generation and verification use the saved contents, even if the original
436
+ YAML file is subsequently edited or removed. Start a new session to apply rule
437
+ changes. Older sessions without a rule snapshot fall back to the currently
438
+ installed default file; start a new session for reproducible custom-rule reviews.
@@ -0,0 +1,123 @@
1
+ ---
2
+ schema_version: 1
3
+ terms:
4
+ abstract-reference:
5
+ terms:
6
+ - コスト
7
+ - 境界
8
+ - 契約
9
+ - 観点
10
+ - 土台
11
+ - 橋渡し
12
+ - 入口
13
+ - 記述
14
+ - 場所
15
+ - 意図
16
+ - 役割
17
+ - 一続き
18
+ - 根拠
19
+ - 部品
20
+ - 開発者
21
+ - 概念
22
+ question: Identify the concrete referent, components, or measurable work. Keep
23
+ established technical meanings.
24
+ weak-predicate:
25
+ terms:
26
+ - 利用します
27
+ - 整理します
28
+ - 扱います
29
+ - 示します
30
+ - 変わります
31
+ - 把握します
32
+ - 分けられます
33
+ - そろえます
34
+ - まとまっています
35
+ - 加えます
36
+ - 探します
37
+ - 到達しません
38
+ - 扱いません
39
+ - そろいます
40
+ - 選べます
41
+ - 成り立たせています
42
+ - 確かめます
43
+ - 書き換える
44
+ - 絞れます
45
+ - 渡します
46
+ - あります
47
+ - 意味しません
48
+ question: Check whether the purpose, operation, or result is clear from the surrounding
49
+ paragraph. Preserve negation and conditions.
50
+ vague-degree:
51
+ terms:
52
+ - 浅い
53
+ - 深い
54
+ question: Identify the concrete depth, level, scope, or comparison. Keep literal
55
+ measurements and established technical meanings.
56
+ contextual-phrase:
57
+ terms:
58
+ - これらを
59
+ - であることだけでは
60
+ - あるものとします
61
+ - わけではありません
62
+ - 別です
63
+ question: Check the referent, assumption, or qualification against the surrounding
64
+ explanation. Preserve conditions and negation.
65
+ generic-framing:
66
+ terms:
67
+ - 重要なのは
68
+ - ポイントは
69
+ - 本章では
70
+ - ここでは
71
+ - まとめると
72
+ question: Check whether this framing adds useful scope or information instead
73
+ of repeating the explanation.
74
+ verbs:
75
+ 扱う:
76
+ - 扱う
77
+ 示す:
78
+ - 示す
79
+ 変わる:
80
+ - 変わる
81
+ 分ける:
82
+ - 分ける
83
+ そろえる:
84
+ - そろえる
85
+ - 揃える
86
+ まとまる:
87
+ - まとまる
88
+ - 纏まる
89
+ 加える:
90
+ - 加える
91
+ 探す:
92
+ - 探す
93
+ そろう:
94
+ - そろう
95
+ - 揃う
96
+ 選ぶ:
97
+ - 選ぶ
98
+ - 選べる
99
+ 成り立つ:
100
+ - 成り立つ
101
+ 確かめる:
102
+ - 確かめる
103
+ 書き換える:
104
+ - 書き換える
105
+ 絞る:
106
+ - 絞る
107
+ - 絞れる
108
+ 渡す:
109
+ - 渡す
110
+ ある:
111
+ - ある
112
+ sahen:
113
+ - 利用
114
+ - 整理
115
+ - 把握
116
+ - 到達
117
+ - 意味
118
+ negative_only:
119
+ - 到達する
120
+ - 意味する
121
+ compound_nouns:
122
+ - 一続き
123
+ - 開発者
@@ -36,8 +36,10 @@ module AsciidocPubkit
36
36
  if command == 'scan'
37
37
  opts.on('--only FILE', 'Review one included file in the book context') { |v| options[:only] = v }
38
38
  opts.on('--config FILE', 'Use an explicit YAML configuration') { |v| options[:config] = v }
39
+ opts.on('--rules FILE', 'Replace default review rules with a YAML rule set') { |v| options[:rules] = v }
39
40
  opts.on('--base-dir DIR', 'Set the Asciidoctor base directory') { |v| options[:base_dir] = v }
40
41
  opts.on('--lang LANG', 'Prose language (ja only)') { |v| options[:language] = v }
42
+ opts.on('--tokenizer NAME', 'mecab (default) or literal (limited phrase matching)') { |v| options[:tokenizer] = v }
41
43
  opts.on('--style STYLE', 'preserve (default), desu-masu, or dearu') { |v| options[:style] = v }
42
44
  opts.on('-a', '--attribute NAME=VALUE', 'Set an Asciidoctor attribute; repeat as needed') do |v|
43
45
  key, value = v.split('=', 2)
@@ -58,6 +60,7 @@ module AsciidocPubkit
58
60
  if command == 'scan'
59
61
  result = Session.scan(args.first, options)
60
62
  out.puts "Scanned #{result['paragraphs']} paragraphs; found #{result['findings']} review candidates."
63
+ out.puts "Tokenizer: #{result['tokenizer']}#{result['tokenizer'] == 'literal' ? ' (limited phrase matching; no morphological analysis)' : ' (UTF-8 IPADIC)'}"
61
64
  out.puts "Coverage notices: #{result['coverage_notices']}. See document.json for limitations."
62
65
  out.puts "Review session: #{result['session']}"
63
66
  return 0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module AsciidocPubkit
6
+ # An adapter for the MeCab CLI with UTF-8 IPADIC features. No shell is used.
7
+ class Morphology
8
+ attr_reader :identity
9
+
10
+ def initialize(settings)
11
+ @command = settings.fetch('mecab_command', 'mecab')
12
+ @args = []
13
+ dictionary = settings['mecab_dictionary']
14
+ @args += ['--dicdir', dictionary] if dictionary
15
+ version = execute(['--version']).strip
16
+ info = execute(@args + ['--dictionary-info'])
17
+ charsets = info.scan(/^charset:\s*(\S+)/).flatten
18
+ unless !charsets.empty? && charsets.all? { |charset| %w[utf8 utf-8].include?(charset.downcase) }
19
+ raise Error, 'MeCab requires a UTF-8 IPADIC dictionary. Set review.mecab_dictionary to its directory.'
20
+ end
21
+ files = info.scan(/^filename:\s*(.+)$/).flatten.map(&:strip)
22
+ raise Error, 'MeCab did not report a dictionary filename.' if files.empty?
23
+ fingerprints = files.flat_map do |file|
24
+ [file, *%w[unk.dic matrix.bin char.bin dicrc].map { |name| File.join(File.dirname(file), name) }]
25
+ end.uniq.to_h do |file|
26
+ raise Error, "MeCab dictionary component is missing: #{file}" unless File.file?(file)
27
+ [File.realpath(file), Digest::SHA256.file(file).hexdigest]
28
+ end
29
+ @identity = { 'engine' => 'mecab', 'version' => version, 'feature_schema' => 'ipadic', 'dictionary_files' => fingerprints }
30
+ probe = tokenize('食べました。浅かった。')
31
+ unless probe.any? { |t| t['lemma'] == '食べる' && t['pos'] == '動詞' } &&
32
+ probe.any? { |t| t['lemma'] == '浅い' && t['pos'] == '形容詞' }
33
+ raise Error, 'Unsupported MeCab dictionary. Use UTF-8 IPADIC; UniDic and other feature schemas are not supported.'
34
+ end
35
+ end
36
+
37
+ def tokenize(text)
38
+ return [] if text.empty?
39
+ raise Error, 'MeCab input contains a NUL character.' if text.include?("\0")
40
+ format = '%m\t%H\t%s\n'
41
+ buffer = [8192, text.lines.map(&:bytesize).max.to_i + 1].max
42
+ output = execute(@args + ['--node-format', format, '--unk-format', format,
43
+ '--bos-format', '', '--eos-format', '', '--input-buffer-size', buffer.to_s], text)
44
+ cursor = 0
45
+ tokens = output.lines.filter_map do |line|
46
+ surface, feature, kind = line.chomp.split("\t", 3)
47
+ raise Error, 'MeCab returned an invalid token record.' unless surface && feature && kind && !surface.empty?
48
+ offset = text.index(surface, cursor)
49
+ unless offset && text[cursor...offset].match?(/\A[[:space:]]*\z/)
50
+ raise Error, 'MeCab token positions could not be aligned with the source text.'
51
+ end
52
+ cursor = offset + surface.length
53
+ fields = feature.split(',', -1)
54
+ if kind == '0' && fields.length != 9
55
+ raise Error, 'Unsupported MeCab feature schema. A UTF-8 IPADIC dictionary is required.'
56
+ end
57
+ {
58
+ 'surface' => surface, 'offset' => offset, 'end_offset' => cursor,
59
+ 'pos' => fields[0], 'pos_detail' => fields[1],
60
+ 'lemma' => fields[6] == '*' ? surface : fields[6], 'unknown' => kind != '0'
61
+ }
62
+ end
63
+ unless text[cursor..].match?(/\A[[:space:]]*\z/)
64
+ raise Error, 'MeCab did not analyze the entire source text.'
65
+ end
66
+ tokens
67
+ end
68
+
69
+ private
70
+
71
+ def execute(args, input = '')
72
+ stdout, stderr, status = Open3.capture3(@command, *args, stdin_data: input)
73
+ # MeCab 0.996 returns 1 after successfully printing dictionary information.
74
+ dictionary_info = args.include?('--dictionary-info') && status.exitstatus == 1 && stderr.empty? && stdout.start_with?('filename:')
75
+ raise Error, "MeCab failed (exit #{status.exitstatus}): #{(stderr.empty? ? stdout : stderr).strip[0, 1000]}" unless status.success? || dictionary_info
76
+ stdout.force_encoding(Encoding::UTF_8)
77
+ raise Error, 'MeCab output is not UTF-8.' unless stdout.valid_encoding?
78
+ stdout
79
+ rescue Errno::ENOENT
80
+ raise Error, 'MeCab is not installed. Install MeCab and UTF-8 IPADIC (macOS: brew install mecab mecab-ipadic), or explicitly use --tokenizer literal for limited phrase matching.'
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciidocPubkit
4
+ class RuleSet
5
+ DEFAULT_PATH = File.expand_path('../../data/review-rules.ja.yml', __dir__)
6
+ CATEGORIES = %w[abstract-reference weak-predicate vague-degree contextual-phrase generic-framing].freeze
7
+ KEYS = %w[schema_version terms verbs sahen negative_only compound_nouns].freeze
8
+
9
+ def self.load(path = DEFAULT_PATH)
10
+ validate(YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false))
11
+ rescue Psych::Exception => e
12
+ raise Error, "Invalid rule YAML in #{path}: #{e.message}"
13
+ end
14
+
15
+ def self.mapping(value, keys, label)
16
+ unless value.is_a?(Hash) && (value.keys - keys).empty? && (keys - value.keys).empty?
17
+ raise Error, "#{label} must contain exactly these keys: #{keys.join(', ')}."
18
+ end
19
+ end
20
+
21
+ def self.strings(value, label)
22
+ unless value.is_a?(Array) && value.all? { |s| s.is_a?(String) && !s.strip.empty? } && value.uniq == value
23
+ raise Error, "#{label} must be an array of unique nonempty strings."
24
+ end
25
+ end
26
+
27
+ def self.validate(data)
28
+ mapping(data, KEYS, 'Rule set')
29
+ raise Error, 'Rule schema_version must be integer 1.' unless data['schema_version'].is_a?(Integer) && data['schema_version'] == 1
30
+ mapping(data['terms'], CATEGORIES, 'Rule terms')
31
+ data['terms'].each do |category, entry|
32
+ mapping(entry, %w[terms question], category)
33
+ strings(entry['terms'], "#{category}.terms")
34
+ raise Error, "#{category}.question must be a nonempty string." unless entry['question'].is_a?(String) && !entry['question'].strip.empty?
35
+ end
36
+ verbs = data['verbs']
37
+ raise Error, 'verbs must be a mapping of canonical forms to lemma arrays.' unless verbs.is_a?(Hash)
38
+ verbs.each do |canonical, forms|
39
+ raise Error, 'Verb canonical forms must be nonempty strings.' unless canonical.is_a?(String) && !canonical.strip.empty?
40
+ strings(forms, "verbs.#{canonical}")
41
+ raise Error, "verbs.#{canonical} must contain at least one lemma." if forms.empty?
42
+ end
43
+ aliases = verbs.values.flatten
44
+ raise Error, 'Verb lemmas must have only one canonical form.' unless aliases.uniq == aliases
45
+ %w[sahen negative_only compound_nouns].each { |key| strings(data[key], key) }
46
+ canonical_forms = verbs.keys + data['sahen'].map { |noun| noun + 'する' }
47
+ raise Error, 'negative_only must refer to configured canonical predicates.' unless (data['negative_only'] - canonical_forms).empty?
48
+ raise Error, 'compound_nouns must also appear in abstract-reference terms.' unless (data['compound_nouns'] - data['terms']['abstract-reference']['terms']).empty?
49
+ data
50
+ end
51
+ end
52
+ end
@@ -3,22 +3,24 @@
3
3
  module AsciidocPubkit
4
4
  class Rules
5
5
  INLINE = /`[^`\n]*`|\+\+\+.*?\+\+\+|\+\+[^\n]*?\+\+|(?<!\w)\+[^+\n]+\+|「[^」\n]*」|『[^』\n]*』|\{[^}\n]+\}|<<[^>\n]+>>|\[\[[^\]\n]+\]\]|(?:link|xref|image|footnote|pass):[^\s\[]*\[[^\]\n]*\]|https?:\/\/[^\s\[\]<>]+(?:\[[^\]\n]*\])?/m
6
- TERMS = {
7
- 'abstract-reference' => [%w[コスト 境界 契約 観点 土台 橋渡し], 'Identify the concrete referent, components, or measurable work. Keep established technical meanings.'],
8
- 'weak-predicate' => [%w[利用します 整理します 扱います 示します], 'Check whether the purpose, operation, or result is clear from the surrounding paragraph.'],
9
- 'generic-framing' => [%w[重要なのは ポイントは 本章では ここでは まとめると], 'Check whether this framing adds useful scope or information instead of repeating the explanation.']
10
- }.freeze
11
-
12
6
  def self.mask(text)
13
7
  # Keep character offsets stable while excluding common inline constructs.
14
8
  text.gsub(INLINE) { |match| match.gsub(/[^\n]/, ' ') }
15
9
  end
16
10
 
17
- def self.scan(paragraphs, settings)
11
+ def self.scan(paragraphs, settings, tokenizer: nil)
12
+ rules = RuleSet.validate(settings.fetch('rules') { RuleSet.load })
13
+ terms = rules.fetch('terms').transform_values { |entry| entry.values_at('terms', 'question') }
18
14
  findings = []
19
- paragraphs.each do |paragraph|
15
+ if settings.fetch('tokenizer', 'mecab') == 'mecab'
16
+ tokenizer ||= Morphology.new(settings)
17
+ end
18
+ token_groups = tokenizer ? tokenize_paragraphs(paragraphs, tokenizer) : []
19
+ paragraphs.each_with_index do |paragraph, index|
20
20
  text = mask(paragraph.fetch('text'))
21
- TERMS.each do |rule, (terms, question)|
21
+ scan_morphemes(findings, paragraph, text, token_groups[index], settings, rules, terms) if tokenizer
22
+ terms.each do |rule, (terms, question)|
23
+ next if tokenizer && !%w[generic-framing contextual-phrase].include?(rule)
22
24
  terms.each do |term|
23
25
  next if settings.fetch('allows').include?(term)
24
26
  find_term(findings, paragraph, text, term, rule, 'hint', question)
@@ -49,6 +51,104 @@ module AsciidocPubkit
49
51
  findings
50
52
  end
51
53
 
54
+ def self.tokenize_paragraphs(paragraphs, tokenizer)
55
+ text = +''
56
+ spans = paragraphs.map do |paragraph|
57
+ start = text.length
58
+ text << mask(paragraph['text'])
59
+ finish = text.length
60
+ text << "\n"
61
+ [start, finish]
62
+ end
63
+ tokens = tokenizer.tokenize(text)
64
+ cursor = 0
65
+ spans.map do |start, finish|
66
+ group = []
67
+ while cursor < tokens.length && tokens[cursor]['offset'] < finish
68
+ token = tokens[cursor]
69
+ raise Error, 'A morphological token crossed a paragraph boundary.' if token['offset'] < start || token['end_offset'] > finish
70
+ group << token.merge('offset' => token['offset'] - start, 'end_offset' => token['end_offset'] - start)
71
+ cursor += 1
72
+ end
73
+ group
74
+ end
75
+ end
76
+
77
+ def self.scan_morphemes(findings, paragraph, text, tokens, settings, rules, terms)
78
+ phrase_ranges = terms['contextual-phrase'][0].flat_map do |phrase|
79
+ text.to_enum(:scan, Regexp.new(Regexp.escape(phrase))).map do
80
+ match = Regexp.last_match
81
+ match.begin(0)...match.end(0)
82
+ end
83
+ end
84
+ tokens.each_with_index do |token, index|
85
+ next if token['unknown'] || phrase_ranges.any? { |range| range.cover?(token['offset']) }
86
+ lemma = token['lemma']
87
+ rule = nil
88
+ finish_index = index
89
+ compound = rules.fetch('compound_nouns').find do |term|
90
+ surface = +''
91
+ cursor = index
92
+ while (member = tokens[cursor]) && member['pos'] == '名詞' && !member['unknown']
93
+ break if cursor > index && tokens[cursor - 1]['end_offset'] != member['offset']
94
+ surface << member['surface']
95
+ break unless term.start_with?(surface)
96
+ if surface == term
97
+ finish_index = cursor
98
+ break
99
+ end
100
+ cursor += 1
101
+ end
102
+ surface == term
103
+ end
104
+ if compound
105
+ lemma = compound
106
+ rule = 'abstract-reference'
107
+ elsif token['pos'] == '名詞' && terms['abstract-reference'][0].include?(lemma)
108
+ rule = 'abstract-reference'
109
+ elsif token['pos'] == '形容詞' && terms['vague-degree'][0].include?(lemma)
110
+ rule = 'vague-degree'
111
+ finish_index = predicate_end(tokens, index, text)
112
+ elsif token['pos'] == '動詞' && (entry = rules.fetch('verbs').find { |_canonical, forms| forms.include?(lemma) })
113
+ lemma = entry[0]
114
+ rule = 'weak-predicate'
115
+ finish_index = predicate_end(tokens, index, text)
116
+ elsif token['pos'] == '名詞' && token['pos_detail'] == 'サ変接続' && rules.fetch('sahen').include?(lemma)
117
+ following = tokens[index + 1]
118
+ next unless following && following['pos'] == '動詞' && following['lemma'] == 'する' && adjacent?(token, following, text)
119
+ lemma += 'する'
120
+ rule = 'weak-predicate'
121
+ finish_index = predicate_end(tokens, index + 1, text)
122
+ end
123
+ next unless rule
124
+ members = tokens[index..finish_index]
125
+ negative = members.any? { |member| member['pos'] == '助動詞' && %w[ない ぬ ん].include?(member['lemma']) }
126
+ next if rules.fetch('negative_only').include?(lemma) && !negative
127
+ surface = text[token['offset']...tokens[finish_index]['end_offset']]
128
+ allows = settings.fetch('allows')
129
+ next if [lemma, token['lemma'], surface, surface + '。'].any? { |form| allows.include?(form) }
130
+ add(findings, paragraph, text, token['offset'], surface, rule, 'hint', terms.fetch(rule)[1])
131
+ findings.last.merge!('lemma' => lemma, 'part_of_speech' => token['pos'], 'negative' => negative,
132
+ 'detector' => 'mecab-ipadic')
133
+ end
134
+ end
135
+
136
+ def self.adjacent?(left, right, text)
137
+ text[left['end_offset']...right['offset']].match?(/\A\n?\z/)
138
+ end
139
+
140
+ def self.predicate_end(tokens, index, text)
141
+ finish = index
142
+ while (following = tokens[finish + 1]) && adjacent?(tokens[finish], following, text)
143
+ auxiliary = following['pos'] == '助動詞'
144
+ dependent_verb = following['pos'] == '動詞' && %w[非自立 接尾].include?(following['pos_detail'])
145
+ connector = following['pos'] == '助詞' && following['pos_detail'] == '接続助詞' && %w[て で].include?(following['lemma'])
146
+ break unless auxiliary || dependent_verb || connector
147
+ finish += 1
148
+ end
149
+ finish
150
+ end
151
+
52
152
  def self.find_term(findings, paragraph, text, term, rule, severity, question)
53
153
  text.to_enum(:scan, Regexp.new(Regexp.escape(term))).each do
54
154
  match = Regexp.last_match
@@ -14,7 +14,9 @@ module AsciidocPubkit
14
14
  end
15
15
  destination = File.expand_path(options.fetch(:output, '.pubkit/review'))
16
16
  raise Error, "Output already exists: #{destination}" if File.exist?(destination)
17
- findings = Rules.scan(document.paragraphs, settings.data)
17
+ tokenizer = settings.data['tokenizer'] == 'mecab' ? Morphology.new(settings.data) : nil
18
+ analysis = tokenizer ? tokenizer.identity : { 'engine' => 'literal' }
19
+ findings = Rules.scan(document.paragraphs, settings.data, tokenizer: tokenizer)
18
20
  parent = File.dirname(destination)
19
21
  FileUtils.mkdir_p(parent)
20
22
  staging = Dir.mktmpdir('.pubkit-', parent)
@@ -29,6 +31,7 @@ module AsciidocPubkit
29
31
  'schema_version' => SCHEMA, 'tool_version' => VERSION,
30
32
  'entry' => File.realpath(entry), 'only' => options[:only] && File.realpath(options[:only]),
31
33
  'settings' => settings.data, 'sources' => sources,
34
+ 'analysis' => analysis,
32
35
  'protected' => protected_content(document),
33
36
  'numeric_tokens' => numeric_tokens(document)
34
37
  }
@@ -43,7 +46,8 @@ module AsciidocPubkit
43
46
  FileUtils.remove_entry(staging) if File.exist?(staging)
44
47
  end
45
48
  { 'session' => destination, 'paragraphs' => document.paragraphs.length,
46
- 'findings' => findings.length, 'coverage_notices' => document.coverage.length }
49
+ 'findings' => findings.length, 'coverage_notices' => document.coverage.length,
50
+ 'tokenizer' => settings.data['tokenizer'] }
47
51
  end
48
52
 
49
53
  def self.write_json(path, value)
@@ -126,6 +130,13 @@ module AsciidocPubkit
126
130
 
127
131
  #{JSON.pretty_generate(@manifest['settings'])}
128
132
 
133
+ ## Analysis backend
134
+
135
+ #{JSON.pretty_generate(@manifest['analysis'])}
136
+
137
+ Morphological findings include a dictionary form and the original inflected surface.
138
+ A negative form must not be rewritten as an affirmative assertion. Keep the original polarity and uncertainty.
139
+
129
140
  ## Coverage
130
141
 
131
142
  Only source-mapped running-prose paragraphs are reviewed. Inline macros and literal spans are masked by a conservative heuristic.
@@ -176,10 +187,15 @@ module AsciidocPubkit
176
187
  changed = @manifest['sources'].filter_map do |source|
177
188
  source['path'] if document.sources[source['path']] && AsciidocPubkit.hash_text(document.sources[source['path']]) != source['sha256']
178
189
  end
190
+ tokenizer = @manifest['settings']['tokenizer'] == 'mecab' ? Morphology.new(@manifest['settings']) : nil
191
+ analysis = tokenizer ? tokenizer.identity : { 'engine' => 'literal' }
192
+ if analysis != @manifest['analysis']
193
+ issues << { 'kind' => 'analyzer-changed', 'message' => 'The MeCab version or dictionary changed. Finish verification with the original analyzer or start a new review pass.' }
194
+ end
179
195
  {
180
196
  'passed' => issues.empty?, 'meaning_verified' => false, 'changed_files' => changed,
181
197
  'issues' => issues, 'notices' => notices, 'coverage' => document.coverage,
182
- 'findings' => Rules.scan(document.paragraphs, @manifest['settings'])
198
+ 'analysis' => analysis, 'findings' => Rules.scan(document.paragraphs, @manifest['settings'], tokenizer: tokenizer)
183
199
  }
184
200
  rescue Error, Errno::ENOENT => e
185
201
  { 'passed' => false, 'meaning_verified' => false, 'issues' => [{ 'kind' => 'verification-error', 'message' => e.message }],
@@ -23,22 +23,29 @@ module AsciidocPubkit
23
23
  reject_keys(config, ['review'], 'configuration')
24
24
  review = config.fetch('review', {})
25
25
  raise Error, 'review must be a mapping.' unless review.is_a?(Hash)
26
- reject_keys(review, %w[language style glossary exclude allows attributes base_dir], 'review')
26
+ reject_keys(review, %w[language style glossary exclude allows attributes base_dir tokenizer mecab_command mecab_dictionary rules], 'review')
27
27
  raise Error, 'attributes must be a mapping.' unless review.fetch('attributes', {}).is_a?(Hash)
28
- %w[base_dir glossary].each do |key|
28
+ %w[base_dir glossary mecab_command mecab_dictionary rules].each do |key|
29
29
  raise Error, "#{key} must be a nonempty path string." if review.key?(key) && (!review[key].is_a?(String) || review[key].empty?)
30
30
  end
31
31
  base = @path ? File.dirname(@path) : File.dirname(File.expand_path(entry))
32
+ rules_path = options[:rules] ? File.expand_path(options[:rules]) : (review['rules'] ? File.expand_path(review['rules'], base) : RuleSet::DEFAULT_PATH)
32
33
  @data = {
34
+ 'rules' => RuleSet.load(rules_path),
35
+ 'rules_path' => rules_path,
33
36
  'language' => options[:language] || review.fetch('language', 'ja'),
34
37
  'style' => options[:style] || review.fetch('style', 'preserve'),
35
38
  'exclude' => review.fetch('exclude', []),
36
39
  'allows' => review.fetch('allows', []),
40
+ 'tokenizer' => options[:tokenizer] || review.fetch('tokenizer', 'mecab'),
41
+ 'mecab_command' => review.fetch('mecab_command', 'mecab'),
42
+ 'mecab_dictionary' => review['mecab_dictionary'] && File.expand_path(review['mecab_dictionary'], base),
37
43
  'attributes' => review.fetch('attributes', {}).merge(options.fetch(:attributes, {})),
38
44
  'base_dir' => File.expand_path(options[:base_dir] || review.fetch('base_dir', base), options[:base_dir] ? Dir.pwd : base),
39
45
  'glossary' => {}
40
46
  }
41
47
  raise Error, 'Only Japanese (ja) is supported in this release.' unless @data['language'] == 'ja'
48
+ raise Error, 'tokenizer must be mecab or literal.' unless %w[mecab literal].include?(@data['tokenizer'])
42
49
  raise Error, 'style must be preserve, desu-masu, or dearu.' unless %w[preserve desu-masu dearu].include?(@data['style'])
43
50
  %w[exclude allows].each do |key|
44
51
  raise Error, "#{key} must be an array of strings." unless @data[key].is_a?(Array) && @data[key].all? { |v| v.is_a?(String) }
@@ -9,7 +9,7 @@ require 'pathname'
9
9
  require 'optparse'
10
10
 
11
11
  module AsciidocPubkit
12
- VERSION = '0.1.0'
12
+ VERSION = '0.1.2'
13
13
  class Error < StandardError; end
14
14
 
15
15
  def self.hash_text(text)
@@ -23,8 +23,10 @@ module AsciidocPubkit
23
23
  end
24
24
  end
25
25
 
26
+ require_relative 'asciidoc_pubkit/rule_set'
26
27
  require_relative 'asciidoc_pubkit/settings'
27
28
  require_relative 'asciidoc_pubkit/document'
29
+ require_relative 'asciidoc_pubkit/morphology'
28
30
  require_relative 'asciidoc_pubkit/rules'
29
31
  require_relative 'asciidoc_pubkit/session'
30
32
  require_relative 'asciidoc_pubkit/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciidoc-pubkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - CyberGarage
@@ -47,11 +47,14 @@ files:
47
47
  - CHANGELOG.md
48
48
  - LICENSE
49
49
  - README.md
50
+ - data/review-rules.ja.yml
50
51
  - examples/book.adoc
51
52
  - exe/asciidoc-pubkit
52
53
  - lib/asciidoc_pubkit.rb
53
54
  - lib/asciidoc_pubkit/cli.rb
54
55
  - lib/asciidoc_pubkit/document.rb
56
+ - lib/asciidoc_pubkit/morphology.rb
57
+ - lib/asciidoc_pubkit/rule_set.rb
55
58
  - lib/asciidoc_pubkit/rules.rb
56
59
  - lib/asciidoc_pubkit/session.rb
57
60
  - lib/asciidoc_pubkit/settings.rb