asciidoc-pubkit 0.1.0 → 0.1.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: 1c17eb36b6d72e3d59299a979d643ff36206c881f3515e33d4ed88a3fb37a711
4
- data.tar.gz: 7ecfa1dc6e556d15b35af4e942919f6700d7c5fae46fc8355fc43467ef31f2e3
3
+ metadata.gz: b5e89872635e44464421539669ea3fb4a76c886a3adae9f5ca1df98fd864d05c
4
+ data.tar.gz: bf081702caf7eca34423eea571fd23ad9dfffc89ed11c9b1d013dd84fb25d57c
5
5
  SHA512:
6
- metadata.gz: 2955fc8990b297e8280080d1cd1e8f3d8f72e976e1d0e2c23845d9543328115f7164111ae1bb9323b24c72c31762e36429e399049ac666d10014aef629b803e1
7
- data.tar.gz: 412ffe4c96f1d00c5f00a8ffc47e0f6f9fa92d5e5fd3fa7af88c911fb51169cfc7e206543fdaac6caaabf41cf7c0c000fcc587912e60c58358b134a1ce1b47c7
6
+ metadata.gz: 93bae7f5ef964afa60b0d6eee95b332270907df107dedae820c09fbbc1829d0d88077df96c20eaff8d9654f31ff6e47390517d1922b6486186dfa04ff17d107a
7
+ data.tar.gz: 5ab456dedf2c322836c827e00a0e2443b3bb9709202d88a0e82203561af98c6953debc601c799355f4df45b744d645f7c4cb670b812c0d7240c374973de09f7c
data/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0Unreleased
3
+ ## 0.1.1Released
4
+
5
+ - Add contextual phrases, compound nouns, and potential and causative verb forms.
6
+ - Add requested abstract nouns, degree adjectives, and vague predicates.
7
+ - Use MeCab with UTF-8 IPADIC by default to match inflected predicates and adjectives.
8
+ - Preserve original surfaces, dictionary forms, and negative auxiliary information.
9
+ - Record analyzer and dictionary fingerprints in review sessions.
10
+ - Add an explicit literal mode for environments without MeCab.
11
+ - Reject incompatible dictionaries and unavailable analyzers with setup guidance.
12
+ - Require a new review session when upgrading from 0.1.0.
13
+
14
+ ## 0.1.0 — Released
4
15
 
5
16
  - Add AsciiDoc running-prose extraction with checked source locations.
6
17
  - Add Japanese review candidates and configurable terminology checks.
data/README.md CHANGED
@@ -1,3 +1,7 @@
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
+
1
5
  # asciidoc-pubkit
2
6
 
3
7
  A toolkit for authoring, reviewing, and publishing AsciiDoc books.
@@ -14,12 +18,15 @@ rewrite manuscripts, or publish books. EPUB, image, and book scaffolding command
14
18
  are planned extensions, not available features.
15
19
 
16
20
  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.
21
+ Starting with version 0.1.1, the default review backend requires the external MeCab
22
+ command and a UTF-8 IPADIC dictionary. Node.js and textlint are not required.
23
+ Explicit `--tokenizer literal` mode provides limited phrase matching without MeCab.
24
+
25
+ Version 0.1.1 includes morphological analysis. Version 0.1.0 uses literal matching.
19
26
 
20
27
  ## Install from RubyGems
21
28
 
22
- After a release is published on RubyGems, install the CLI with:
29
+ Install the CLI from [RubyGems](https://rubygems.org/gems/asciidoc-pubkit):
23
30
 
24
31
  ```sh
25
32
  gem install asciidoc-pubkit
@@ -28,8 +35,8 @@ asciidoc-pubkit --help
28
35
  ```
29
36
 
30
37
  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.
38
+ no repository clone or Node.js installation is needed. MeCab and IPADIC must be
39
+ installed separately when using version 0.1.1 or later in the default mode.
33
40
 
34
41
  Run the review workflow from your manuscript directory:
35
42
 
@@ -52,7 +59,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
52
59
 
53
60
  ```ruby
54
61
  source 'https://rubygems.org'
55
- gem 'asciidoc-pubkit', '~> 0.1.0'
62
+ gem 'asciidoc-pubkit', '~> 0.1.1'
56
63
  ```
57
64
 
58
65
  Then install dependencies and run the CLI through Bundler:
@@ -74,7 +81,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
74
81
  cd asciidoc-pubkit
75
82
  bundle install
76
83
  gem build asciidoc-pubkit.gemspec
77
- gem install ./asciidoc-pubkit-0.1.0.gem
84
+ gem install ./asciidoc-pubkit-0.1.1.gem
78
85
  asciidoc-pubkit --version
79
86
  ```
80
87
 
@@ -85,6 +92,36 @@ For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
85
92
  paragraphs deliberately contain review candidates; its code block must remain
86
93
  unchanged.
87
94
 
95
+ ## Install the morphological analyzer (0.1.1 and later)
96
+
97
+ On macOS with Homebrew:
98
+
99
+ ```sh
100
+ brew install mecab mecab-ipadic
101
+ mecab -D
102
+ ```
103
+
104
+ On Ubuntu or Debian:
105
+
106
+ ```sh
107
+ sudo apt-get update
108
+ sudo apt-get install mecab mecab-ipadic-utf8
109
+ mecab -D
110
+ ```
111
+
112
+ Use a UTF-8 IPADIC dictionary. If the default dictionary is different, configure
113
+ `review.mecab_dictionary` with the IPADIC directory reported by your package
114
+ manager. UniDic and other feature layouts are rejected explicitly. MeCab and
115
+ IPADIC are separately installed dependencies, not bundled inside this gem.
116
+
117
+ The tool checks MeCab availability, dictionary encoding, and feature layout.
118
+ It never silently falls back to literal matching. To deliberately run without
119
+ morphological analysis:
120
+
121
+ ```sh
122
+ asciidoc-pubkit review scan book.adoc --tokenizer literal --output .pubkit/literal-review
123
+ ```
124
+
88
125
  ## Review workflow
89
126
 
90
127
  Run the following commands from the manuscript project directory:
@@ -111,7 +148,7 @@ asciidoc-pubkit review scan book.adoc --attribute edition=print --base-dir .
111
148
 
112
149
  An entrypoint or a standalone chapter can be scanned. Includes and conditionals
113
150
  are processed by Asciidoctor. Match the publishing build's attributes and base
114
- directory to review the intended edition. `--only` selects one included source
151
+ directory to review the intended edition. `--only` selects one included source
115
152
  file while retaining the book's attributes and heading hierarchy. Its path is
116
153
  relative to the current working directory, as are other CLI path arguments.
117
154
 
@@ -190,6 +227,10 @@ The initial release loads one configuration file, not merged book/repository fil
190
227
  review:
191
228
  language: ja
192
229
  style: desu-masu
230
+ tokenizer: mecab
231
+ # Optional overrides (dictionary paths are relative to this file):
232
+ # mecab_command: /opt/homebrew/bin/mecab
233
+ # mecab_dictionary: /opt/homebrew/lib/mecab/dic/ipadic
193
234
  base_dir: .
194
235
  glossary: glossary.yml
195
236
  exclude:
@@ -213,10 +254,13 @@ JavaScript:
213
254
  - Java Script
214
255
  ```
215
256
 
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.
257
+ Variants are review candidates, not automatic replacement instructions. In MeCab
258
+ mode, `allows` can suppress a canonical dictionary form (all its inflections) or
259
+ an exact matched surface (that occurrence's form only). In literal mode it
260
+ suppresses exact dictionary entries. It does not disable glossary checks.
261
+ Regex patterns are not interpreted in glossary or allow-list entries. Unknown
262
+ configuration keys are rejected. A bare `mecab_command` is resolved through PATH;
263
+ use an absolute path for an explicit executable override.
220
264
 
221
265
  ## Rules and coverage
222
266
 
@@ -224,11 +268,44 @@ Unknown configuration keys are rejected.
224
268
  | --- | --- | --- |
225
269
  | `abstract-reference` | hint | Ask what an abstract noun refers to |
226
270
  | `weak-predicate` | hint | Ask whether an operation's purpose or result is clear |
271
+ | `contextual-phrase` | hint | Review referents, assumptions, and qualifications while preserving negation |
227
272
  | `generic-framing` | hint | Review generic introductions and emphasis |
273
+ | `vague-degree` | hint | Ask what depth, level, scope, or comparison is intended |
228
274
  | `repeated-ending` | info | Identify three consecutive sentences with the same detected ending |
229
275
  | `glossary-variant` | warning | Identify project-specific terminology variants |
230
276
  | `style-candidate` | hint | Check selected polite/plain endings against an explicit style |
231
277
 
278
+ MeCab mode matches noun and adjective tokens and dictionary forms of verbs.
279
+ Sahen predicates are matched as a noun followed by the verb for "do"; standalone
280
+ sahen nouns are not treated as verbal predicates. Auxiliary sequences retain
281
+ negation, past tense, passive forms, and progressive forms in the reported surface.
282
+ The added reach predicate is negative-only; the existing handling predicate is
283
+ reviewed in both affirmative and negative forms. Glossary variants and generic
284
+ framing phrases continue to use literal matching. Contextual phrases also use
285
+ literal matching and suppress overlapping morphological candidates. Compound
286
+ nouns are matched across adjacent noun tokens. Selection and narrowing verbs
287
+ include potential forms; predicate surfaces also preserve causative auxiliaries.
288
+
289
+ Morphological candidates include `lemma`, `part_of_speech`, `negative`, and
290
+ `detector` alongside the original `match`, line, and column. Negation detection
291
+ covers common IPADIC negative auxiliaries; it is not full semantic analysis of
292
+ negation scope or double negatives. Unknown tokens are not guessed. Kana/kanji
293
+ variants of the alignment and gathering verbs have explicit canonical mappings;
294
+ other spelling variants are not automatically normalized.
295
+
296
+ The session records the MeCab version and dictionary file hashes. Verification
297
+ reports analyzer changes instead of treating results from different dictionaries
298
+ as directly comparable. Prompt generation uses saved evidence and does not need
299
+ MeCab. Changed rules or dictionary settings require a new scan.
300
+
301
+ Sessions from 0.1.0 are not compatible with 0.1.1. Keep the original baseline for
302
+ an ongoing review and finish it with the original version, or start a new review
303
+ pass in a different directory:
304
+
305
+ ```sh
306
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.1.1
307
+ ```
308
+
232
309
  Severity describes review priority, not proof of an error. There is no AI-authorship
233
310
  score and no requirement to eliminate every match.
234
311
 
@@ -254,7 +331,9 @@ gem build asciidoc-pubkit.gemspec
254
331
  ```
255
332
 
256
333
  The tests exercise include-boundary mapping, inline masking, configuration,
257
- contextual prompts, stale inputs, and protected-content verification.
334
+ contextual prompts, stale inputs, protected-content verification, and real MeCab
335
+ analysis of inflections, negative predicates, Unicode positions, and long lines.
336
+ Install MeCab and UTF-8 IPADIC before running the complete test suite.
258
337
  GitHub Actions is configured for Ruby 3.2, 3.3, 3.4, and 4.0 on Linux.
259
338
 
260
339
  ## License
@@ -38,6 +38,7 @@ module AsciidocPubkit
38
38
  opts.on('--config FILE', 'Use an explicit YAML configuration') { |v| options[:config] = v }
39
39
  opts.on('--base-dir DIR', 'Set the Asciidoctor base directory') { |v| options[:base_dir] = v }
40
40
  opts.on('--lang LANG', 'Prose language (ja only)') { |v| options[:language] = v }
41
+ opts.on('--tokenizer NAME', 'mecab (default) or literal (limited phrase matching)') { |v| options[:tokenizer] = v }
41
42
  opts.on('--style STYLE', 'preserve (default), desu-masu, or dearu') { |v| options[:style] = v }
42
43
  opts.on('-a', '--attribute NAME=VALUE', 'Set an Asciidoctor attribute; repeat as needed') do |v|
43
44
  key, value = v.split('=', 2)
@@ -58,6 +59,7 @@ module AsciidocPubkit
58
59
  if command == 'scan'
59
60
  result = Session.scan(args.first, options)
60
61
  out.puts "Scanned #{result['paragraphs']} paragraphs; found #{result['findings']} review candidates."
62
+ out.puts "Tokenizer: #{result['tokenizer']}#{result['tokenizer'] == 'literal' ? ' (limited phrase matching; no morphological analysis)' : ' (UTF-8 IPADIC)'}"
61
63
  out.puts "Coverage notices: #{result['coverage_notices']}. See document.json for limitations."
62
64
  out.puts "Review session: #{result['session']}"
63
65
  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
@@ -4,21 +4,39 @@ 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
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.'],
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. Preserve negation and conditions.'],
9
+ 'vague-degree' => [%w[浅い 深い], 'Identify the concrete depth, level, scope, or comparison. Keep literal measurements and established technical meanings.'],
10
+ 'contextual-phrase' => [%w[これらを であることだけでは あるものとします わけではありません], 'Check the referent, assumption, or qualification against the surrounding explanation. Preserve conditions and negation.'],
9
11
  'generic-framing' => [%w[重要なのは ポイントは 本章では ここでは まとめると], 'Check whether this framing adds useful scope or information instead of repeating the explanation.']
10
12
  }.freeze
13
+ VERBS = {
14
+ '扱う' => %w[扱う], '示す' => %w[示す], '変わる' => %w[変わる],
15
+ '分ける' => %w[分ける], 'そろえる' => %w[そろえる 揃える],
16
+ 'まとまる' => %w[まとまる 纏まる], '加える' => %w[加える],
17
+ '探す' => %w[探す], 'そろう' => %w[そろう 揃う],
18
+ '選ぶ' => %w[選ぶ 選べる], '成り立つ' => %w[成り立つ],
19
+ '確かめる' => %w[確かめる], '書き換える' => %w[書き換える],
20
+ '絞る' => %w[絞る 絞れる], '渡す' => %w[渡す], 'ある' => %w[ある]
21
+ }.freeze
22
+ SAHEN = %w[利用 整理 把握 到達].freeze
11
23
 
12
24
  def self.mask(text)
13
25
  # Keep character offsets stable while excluding common inline constructs.
14
26
  text.gsub(INLINE) { |match| match.gsub(/[^\n]/, ' ') }
15
27
  end
16
28
 
17
- def self.scan(paragraphs, settings)
29
+ def self.scan(paragraphs, settings, tokenizer: nil)
18
30
  findings = []
19
- paragraphs.each do |paragraph|
31
+ if settings.fetch('tokenizer', 'mecab') == 'mecab'
32
+ tokenizer ||= Morphology.new(settings)
33
+ end
34
+ token_groups = tokenizer ? tokenize_paragraphs(paragraphs, tokenizer) : []
35
+ paragraphs.each_with_index do |paragraph, index|
20
36
  text = mask(paragraph.fetch('text'))
37
+ scan_morphemes(findings, paragraph, text, token_groups[index], settings) if tokenizer
21
38
  TERMS.each do |rule, (terms, question)|
39
+ next if tokenizer && !%w[generic-framing contextual-phrase].include?(rule)
22
40
  terms.each do |term|
23
41
  next if settings.fetch('allows').include?(term)
24
42
  find_term(findings, paragraph, text, term, rule, 'hint', question)
@@ -49,6 +67,96 @@ module AsciidocPubkit
49
67
  findings
50
68
  end
51
69
 
70
+ def self.tokenize_paragraphs(paragraphs, tokenizer)
71
+ text = +''
72
+ spans = paragraphs.map do |paragraph|
73
+ start = text.length
74
+ text << mask(paragraph['text'])
75
+ finish = text.length
76
+ text << "\n"
77
+ [start, finish]
78
+ end
79
+ tokens = tokenizer.tokenize(text)
80
+ cursor = 0
81
+ spans.map do |start, finish|
82
+ group = []
83
+ while cursor < tokens.length && tokens[cursor]['offset'] < finish
84
+ token = tokens[cursor]
85
+ raise Error, 'A morphological token crossed a paragraph boundary.' if token['offset'] < start || token['end_offset'] > finish
86
+ group << token.merge('offset' => token['offset'] - start, 'end_offset' => token['end_offset'] - start)
87
+ cursor += 1
88
+ end
89
+ group
90
+ end
91
+ end
92
+
93
+ def self.scan_morphemes(findings, paragraph, text, tokens, settings)
94
+ phrase_ranges = TERMS['contextual-phrase'][0].flat_map do |phrase|
95
+ text.to_enum(:scan, Regexp.new(Regexp.escape(phrase))).map do
96
+ match = Regexp.last_match
97
+ match.begin(0)...match.end(0)
98
+ end
99
+ end
100
+ tokens.each_with_index do |token, index|
101
+ next if token['unknown'] || phrase_ranges.any? { |range| range.cover?(token['offset']) }
102
+ lemma = token['lemma']
103
+ rule = nil
104
+ finish_index = index
105
+ compound = %w[一続き 開発者].find do |term|
106
+ following = tokens[index + 1]
107
+ following && token['pos'] == '名詞' && following['pos'] == '名詞' &&
108
+ !following['unknown'] && token['end_offset'] == following['offset'] &&
109
+ token['surface'] + following['surface'] == term
110
+ end
111
+ if compound
112
+ lemma = compound
113
+ finish_index = index + 1
114
+ rule = 'abstract-reference'
115
+ elsif token['pos'] == '名詞' && TERMS['abstract-reference'][0].include?(lemma)
116
+ rule = 'abstract-reference'
117
+ elsif token['pos'] == '形容詞' && TERMS['vague-degree'][0].include?(lemma)
118
+ rule = 'vague-degree'
119
+ finish_index = predicate_end(tokens, index, text)
120
+ elsif token['pos'] == '動詞' && (entry = VERBS.find { |_canonical, forms| forms.include?(lemma) })
121
+ lemma = entry[0]
122
+ rule = 'weak-predicate'
123
+ finish_index = predicate_end(tokens, index, text)
124
+ elsif token['pos'] == '名詞' && token['pos_detail'] == 'サ変接続' && SAHEN.include?(lemma)
125
+ following = tokens[index + 1]
126
+ next unless following && following['pos'] == '動詞' && following['lemma'] == 'する' && adjacent?(token, following, text)
127
+ lemma += 'する'
128
+ rule = 'weak-predicate'
129
+ finish_index = predicate_end(tokens, index + 1, text)
130
+ end
131
+ next unless rule
132
+ members = tokens[index..finish_index]
133
+ negative = members.any? { |member| member['pos'] == '助動詞' && %w[ない ぬ ん].include?(member['lemma']) }
134
+ next if lemma == '到達する' && !negative
135
+ surface = text[token['offset']...tokens[finish_index]['end_offset']]
136
+ allows = settings.fetch('allows')
137
+ next if [lemma, token['lemma'], surface, surface + '。'].any? { |form| allows.include?(form) }
138
+ add(findings, paragraph, text, token['offset'], surface, rule, 'hint', TERMS.fetch(rule)[1])
139
+ findings.last.merge!('lemma' => lemma, 'part_of_speech' => token['pos'], 'negative' => negative,
140
+ 'detector' => 'mecab-ipadic')
141
+ end
142
+ end
143
+
144
+ def self.adjacent?(left, right, text)
145
+ text[left['end_offset']...right['offset']].match?(/\A\n?\z/)
146
+ end
147
+
148
+ def self.predicate_end(tokens, index, text)
149
+ finish = index
150
+ while (following = tokens[finish + 1]) && adjacent?(tokens[finish], following, text)
151
+ auxiliary = following['pos'] == '助動詞'
152
+ dependent_verb = following['pos'] == '動詞' && %w[非自立 接尾].include?(following['pos_detail'])
153
+ connector = following['pos'] == '助詞' && following['pos_detail'] == '接続助詞' && %w[て で].include?(following['lemma'])
154
+ break unless auxiliary || dependent_verb || connector
155
+ finish += 1
156
+ end
157
+ finish
158
+ end
159
+
52
160
  def self.find_term(findings, paragraph, text, term, rule, severity, question)
53
161
  text.to_enum(:scan, Regexp.new(Regexp.escape(term))).each do
54
162
  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,9 +23,9 @@ 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], '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].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))
@@ -34,11 +34,15 @@ module AsciidocPubkit
34
34
  'style' => options[:style] || review.fetch('style', 'preserve'),
35
35
  'exclude' => review.fetch('exclude', []),
36
36
  'allows' => review.fetch('allows', []),
37
+ 'tokenizer' => options[:tokenizer] || review.fetch('tokenizer', 'mecab'),
38
+ 'mecab_command' => review.fetch('mecab_command', 'mecab'),
39
+ 'mecab_dictionary' => review['mecab_dictionary'] && File.expand_path(review['mecab_dictionary'], base),
37
40
  'attributes' => review.fetch('attributes', {}).merge(options.fetch(:attributes, {})),
38
41
  'base_dir' => File.expand_path(options[:base_dir] || review.fetch('base_dir', base), options[:base_dir] ? Dir.pwd : base),
39
42
  'glossary' => {}
40
43
  }
41
44
  raise Error, 'Only Japanese (ja) is supported in this release.' unless @data['language'] == 'ja'
45
+ raise Error, 'tokenizer must be mecab or literal.' unless %w[mecab literal].include?(@data['tokenizer'])
42
46
  raise Error, 'style must be preserve, desu-masu, or dearu.' unless %w[preserve desu-masu dearu].include?(@data['style'])
43
47
  %w[exclude allows].each do |key|
44
48
  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.1'
13
13
  class Error < StandardError; end
14
14
 
15
15
  def self.hash_text(text)
@@ -25,6 +25,7 @@ end
25
25
 
26
26
  require_relative 'asciidoc_pubkit/settings'
27
27
  require_relative 'asciidoc_pubkit/document'
28
+ require_relative 'asciidoc_pubkit/morphology'
28
29
  require_relative 'asciidoc_pubkit/rules'
29
30
  require_relative 'asciidoc_pubkit/session'
30
31
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - CyberGarage
@@ -52,6 +52,7 @@ files:
52
52
  - lib/asciidoc_pubkit.rb
53
53
  - lib/asciidoc_pubkit/cli.rb
54
54
  - lib/asciidoc_pubkit/document.rb
55
+ - lib/asciidoc_pubkit/morphology.rb
55
56
  - lib/asciidoc_pubkit/rules.rb
56
57
  - lib/asciidoc_pubkit/session.rb
57
58
  - lib/asciidoc_pubkit/settings.rb