asciidoc-pubkit 0.8.1 → 0.8.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: 8340a490160e7c0c2ca089657884c882eca5861dd27206dc9a3513ab1b5cfa2d
4
- data.tar.gz: b4c2428c0408f2301563ee3e4586e9422c7b4875f927779146d9ebacec678568
3
+ metadata.gz: 3dd09ef810a52e64bd286b34f4f59cda2cf357214abd94a1255ee44d77a96e27
4
+ data.tar.gz: fec7d11ca4f985b41aa1d2e5d37c8b126d56a1dc1ee86874330d2239634207c1
5
5
  SHA512:
6
- metadata.gz: b919c53366fb83912221bf4a642b23db13725535c82449e54f6859d6a6bc76a04582c7b859eb4fb4f9cde814637dd535625eea31ffd62af48f02a6a71642c4f5
7
- data.tar.gz: 18d123157532330403229b1365b97e2dcba308de2ed5d07aa16583c973d801bfa1e6aac001f299be7ab445beaf7a1600f204ff058362f1cd84dc7362f7466aca
6
+ metadata.gz: e56e14f6abd189a621a3f4b918d9e24697749bc1a777816b2cdfa9a02bfc662f1b53e831e291af99b5254ac976a6fa72a2703c224d98ce979e0d9d7eac5be6d5
7
+ data.tar.gz: 19361ba968e9738a736f4452eb6590b90cbc1ca998e128d0cdd952f1da527c66d97817f6913f9957092c8dfbae55b5019e90909e1709337671bb57384031da06
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 — 2026-10-02
4
+
5
+ - Report replacement rule errors with the original YAML filename and one-based
6
+ line number on the first line and the existing reason on the following line.
7
+ Track imported rules, pattern arrays, specs, and runtime replacement references
8
+ without changing exit codes or applying invalid plans.
9
+
3
10
  ## 0.8.1 — 2026-10-01
4
11
 
5
12
  - Add an opening README feature table and describe prose replacements before
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  A toolkit for authoring and reviewing AsciiDoc books.
9
9
 
10
- Version 0.8.1 provides shared Japanese technical writing criteria, an authoring
10
+ Version 0.8.2 provides shared Japanese technical writing criteria, an authoring
11
11
  prompt, separate prose and heading review prompts, manuscript scoring, and
12
12
  baseline verification for edited manuscripts, plus explicit prose-only mechanical
13
13
  replacements using a limited prh-format rule set.
@@ -16,7 +16,7 @@ Japanese text is retained in manuscript excerpts, rule dictionaries, and fixture
16
16
 
17
17
  ## Available features
18
18
 
19
- The following commands are available in version 0.8.1.
19
+ The following commands are available in version 0.8.2.
20
20
 
21
21
  | Feature | Commands | Behavior |
22
22
  | --- | --- | --- |
@@ -107,7 +107,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
107
107
 
108
108
  ```ruby
109
109
  source 'https://rubygems.org'
110
- gem 'asciidoc-pubkit', '~> 0.8.1'
110
+ gem 'asciidoc-pubkit', '~> 0.8.2'
111
111
  ```
112
112
 
113
113
  Then install dependencies and run the CLI through Bundler:
@@ -129,7 +129,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
129
129
  cd asciidoc-pubkit
130
130
  bundle install
131
131
  gem build asciidoc-pubkit.gemspec
132
- gem install ./asciidoc-pubkit-0.8.1.gem
132
+ gem install ./asciidoc-pubkit-0.8.2.gem
133
133
  asciidoc-pubkit --version
134
134
  ```
135
135
 
@@ -153,7 +153,7 @@ asciidoc-pubkit writing prompt --lang ja --output writing-prompt.md
153
153
  ```
154
154
 
155
155
  For example, `--lang en` exits with an unsupported-language error. No English
156
- review or writing criteria are shipped in 0.8.1.
156
+ review or writing criteria are shipped in 0.8.2.
157
157
 
158
158
  For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
159
159
  paragraphs deliberately contain review candidates; its code block must remain
@@ -180,6 +180,19 @@ authorizes source replacement without prompting. Successful diff/apply commands
180
180
  return 0; invalid rules, conflicts, unsafe edits, and input/output failures return
181
181
  2. Check/diff accept `--output` for a new file only; apply rejects `--output`.
182
182
 
183
+ Starting with version 0.8.2, invalid rule files report their original YAML file
184
+ and one-based line number on the first error line, followed by the existing
185
+ reason on the next line. Imported files and entries in pattern arrays retain
186
+ their own source locations. For example:
187
+
188
+ ```text
189
+ Error: /path/to/prh.yml:162
190
+ Unsupported regex flags; only optional i is supported (all matches are collected).
191
+ ```
192
+
193
+ Rules are validated before any manuscript is replaced. All replacement commands
194
+ stop with exit code 2 on invalid rules, including errors found in imported files.
195
+
183
196
  All three accept `--lang ja`, `--base-dir`, `--config`, and `--only` (an included
184
197
  source file). Normal configuration discovery and `review.base_dir`, `language`,
185
198
  `attributes`, and `exclude` control parsing and selection. `--rules` is required;
@@ -1044,12 +1057,12 @@ reports analyzer changes instead of treating results from different dictionaries
1044
1057
  as directly comparable. Prompt generation uses saved evidence and does not need
1045
1058
  MeCab. Changed rules or dictionary settings require a new scan.
1046
1059
 
1047
- Sessions from earlier tool versions are not compatible with 0.8.1 (session schema 2). Keep the original baseline for
1060
+ Sessions from earlier tool versions are not compatible with 0.8.2 (session schema 2). Keep the original baseline for
1048
1061
  an ongoing review and finish it with the original version, or start a new review
1049
1062
  pass in a different directory:
1050
1063
 
1051
1064
  ```sh
1052
- asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.1
1065
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.2
1053
1066
  ```
1054
1067
 
1055
1068
  Severity describes review priority, not proof of an error. There is no AI-authorship
@@ -5,56 +5,82 @@ require 'tempfile'
5
5
  require 'open3'
6
6
 
7
7
  module AsciidocPubkit
8
+ class ReplacementRuleError < Error
9
+ def initialize(path, node, message, line: nil)
10
+ super("#{path}:#{line || (node ? node.start_line + 1 : 1)}\n#{message}")
11
+ end
12
+ end
13
+
8
14
  class ReplacementRules
15
+ def self.field(node, name)
16
+ return unless node.is_a?(Psych::Nodes::Mapping)
17
+ pair = node.children.each_slice(2).find { |key, _| key.respond_to?(:value) && key.value == name }
18
+ pair && pair.last
19
+ end
20
+
21
+ def self.item(node, index)
22
+ node.is_a?(Psych::Nodes::Sequence) ? node.children[index] : node
23
+ end
24
+
9
25
  def self.load(path)
10
- collect(path, [], {}).each_with_index.map do |rule, index|
11
- unless rule.is_a?(Hash) && (rule.keys - %w[expected pattern patterns specs]).empty? &&
12
- rule['expected'].is_a?(String) && (rule.key?('pattern') ^ rule.key?('patterns'))
13
- raise Error, "Rule #{index + 1} requires expected and exactly one of pattern or patterns; unsupported keys are rejected."
14
- end
15
- patterns = rule.key?('pattern') ? rule['pattern'] : rule['patterns']
16
- patterns = [patterns] if rule.key?('pattern') && patterns.is_a?(String)
17
- unless patterns.is_a?(Array) && !patterns.empty? && patterns.all? { |p| p.is_a?(String) && !p.empty? }
18
- raise Error, 'Replacement patterns must be nonempty strings.'
19
- end
20
- compiled = patterns.map do |pattern|
21
- if pattern.start_with?('/')
22
- match = pattern.match(%r{\A/(.*)/([a-z]*)\z}m)
23
- unless match && ['', 'i'].include?(match[2])
24
- raise Error, 'Unsupported regex flags; only optional i is supported (all matches are collected).'
25
- end
26
- body = match[1]
27
- # Deliberately small common JavaScript/Ruby subset; Ruby-only escapes
28
- # and engine-specific groups must not silently change JS semantics.
29
- if body.match?(/\\[^\\\/.*+?()\[\]{}^$|nrtdDsSwWbB-]/) || body.match?(/\(\?(?![:=!]|<[=!])/) || body.include?('&&')
30
- raise Error, 'Unsupported regex syntax in replacement rule.'
31
- end
32
- ignore_case = match[2] == 'i'
33
- body = javascript_boundaries(body, ignore_case: ignore_case)
34
- Regexp.new(body.gsub('\\/', '/'), ignore_case ? Regexp::IGNORECASE : 0, timeout: 0.1)
35
- else
36
- Regexp.new(Regexp.escape(pattern), timeout: 0.1)
37
- end
38
- end
39
- compiled.each do |regex|
40
- regex.match('') # Detect common empty matches; runtime checks cover contextual ones.
41
- raise Error, 'Zero-length replacement matches are unsupported.' if regex.match('')
42
- end
43
- specs = rule.fetch('specs', [])
44
- raise Error, 'specs must be an array.' unless specs.is_a?(Array)
45
- specs.each do |spec|
46
- RuleSet.mapping(spec, %w[from to], 'Replacement spec')
47
- raise Error, 'Spec from/to must be strings.' unless spec.values.all? { |v| v.is_a?(String) }
48
- result = spec['from'].dup
49
- edits = matches(spec['from'], compiled, rule['expected'])
50
- reject_overlaps!(edits)
51
- edits.reverse_each { |edit| result[edit[:start]...edit[:finish]] = edit[:after] }
52
- raise Error, "Replacement spec failed for rule #{index + 1}." unless result == spec['to']
53
- end
54
- { id: "rule-#{index + 1}", patterns: compiled, expected: rule['expected'] }
26
+ collect(path, [], {}).each_with_index.map { |source, index| compile(source, index) }
27
+ end
28
+
29
+ def self.compile(source, index)
30
+ rule = source[:value]
31
+ location = source[:node]
32
+ unless rule.is_a?(Hash) && (rule.keys - %w[expected pattern patterns specs]).empty? &&
33
+ rule['expected'].is_a?(String) && (rule.key?('pattern') ^ rule.key?('patterns'))
34
+ raise Error, "Rule #{index + 1} requires expected and exactly one of pattern or patterns; unsupported keys are rejected."
35
+ end
36
+ pattern_node = field(source[:node], rule.key?('pattern') ? 'pattern' : 'patterns')
37
+ location = pattern_node
38
+ patterns = rule.key?('pattern') ? rule['pattern'] : rule['patterns']
39
+ patterns = [patterns] if rule.key?('pattern') && patterns.is_a?(String)
40
+ unless patterns.is_a?(Array) && !patterns.empty? && patterns.all? { |p| p.is_a?(String) && !p.empty? }
41
+ raise Error, 'Replacement patterns must be nonempty strings.'
55
42
  end
43
+ compiled = patterns.each_with_index.map do |pattern, pattern_index|
44
+ location = item(pattern_node, pattern_index)
45
+ regex = if pattern.start_with?('/')
46
+ match = pattern.match(%r{\A/(.*)/([a-z]*)\z}m)
47
+ unless match && ['', 'i'].include?(match[2])
48
+ raise Error, 'Unsupported regex flags; only optional i is supported (all matches are collected).'
49
+ end
50
+ body = match[1]
51
+ # Deliberately small common JavaScript/Ruby subset; Ruby-only escapes
52
+ # and engine-specific groups must not silently change JS semantics.
53
+ if body.match?(/\\[^\\\/.*+?()\[\]{}^$|nrtdDsSwWbB-]/) || body.match?(/\(\?(?![:=!]|<[=!])/) || body.include?('&&')
54
+ raise Error, 'Unsupported regex syntax in replacement rule.'
55
+ end
56
+ ignore_case = match[2] == 'i'
57
+ body = javascript_boundaries(body, ignore_case: ignore_case)
58
+ Regexp.new(body.gsub('\\/', '/'), ignore_case ? Regexp::IGNORECASE : 0, timeout: 0.1)
59
+ else
60
+ Regexp.new(Regexp.escape(pattern), timeout: 0.1)
61
+ end
62
+ raise Error, 'Zero-length replacement matches are unsupported.' if regex.match('')
63
+ regex
64
+ end
65
+ location = field(source[:node], 'specs') || source[:node]
66
+ specs = rule.fetch('specs', [])
67
+ raise Error, 'specs must be an array.' unless specs.is_a?(Array)
68
+ specs.each_with_index do |spec, spec_index|
69
+ location = item(field(source[:node], 'specs'), spec_index)
70
+ RuleSet.mapping(spec, %w[from to], 'Replacement spec')
71
+ raise Error, 'Spec from/to must be strings.' unless spec.values.all? { |v| v.is_a?(String) }
72
+ result = spec['from'].dup
73
+ edits = matches(spec['from'], compiled, rule['expected'])
74
+ reject_overlaps!(edits)
75
+ edits.reverse_each { |edit| result[edit[:start]...edit[:finish]] = edit[:after] }
76
+ raise Error, "Replacement spec failed for rule #{index + 1}." unless result == spec['to']
77
+ end
78
+ { id: "rule-#{index + 1}", patterns: compiled, expected: rule['expected'],
79
+ path: source[:path], node: source[:node], pattern_node: pattern_node }
56
80
  rescue RegexpError, Regexp::TimeoutError => e
57
- raise Error, "Invalid replacement regex: #{e.message}"
81
+ raise ReplacementRuleError.new(source[:path], location, "Invalid replacement regex: #{e.message}")
82
+ rescue Error => e
83
+ raise ReplacementRuleError.new(source[:path], location, e.message)
58
84
  end
59
85
 
60
86
  # prh uses Unicode JS regexes: word characters are ASCII letters/digits/_;
@@ -92,25 +118,35 @@ module AsciidocPubkit
92
118
  end
93
119
 
94
120
  def self.collect(path, active, visited)
121
+ path = File.expand_path(path)
122
+ location = nil
95
123
  path = File.realpath(path)
96
124
  if active.include?(path)
97
125
  raise Error, "Circular replacement imports: #{(active + [path]).join(' -> ')}"
98
126
  end
99
127
  return [] if visited[path]
100
128
  raise Error, 'Replacement imports exceed the maximum depth of 100 files.' if active.length >= 100
101
- data = YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false)
129
+ text = AsciidocPubkit.read_text(path)
130
+ tree = Psych.parse_stream(text, filename: path)
131
+ root = tree.children.first&.root
132
+ location = root
133
+ data = YAML.safe_load(text, permitted_classes: [], aliases: false)
102
134
  unless data.is_a?(Hash) && data.key?('version') && (data.keys - %w[version rules imports]).empty?
103
135
  raise Error, "Replacement rules in #{path} require version and accept only rules and imports."
104
136
  end
137
+ location = field(root, 'version')
105
138
  unless data['version'].is_a?(Integer) && data['version'] == 1
106
139
  raise Error, "Replacement version in #{path} must be integer 1."
107
140
  end
108
141
  rules = data.fetch('rules', [])
109
142
  rules = [] if rules.nil?
110
143
  imports = data.fetch('imports', [])
144
+ location = field(root, 'rules')
111
145
  raise Error, "rules in #{path} must be an array." unless rules.is_a?(Array)
146
+ location = field(root, 'imports')
112
147
  raise Error, "imports in #{path} must be an array." unless imports.is_a?(Array)
113
- imported = imports.flat_map do |entry|
148
+ imported = imports.each_with_index.flat_map do |entry, index|
149
+ location = item(field(root, 'imports'), index)
114
150
  if entry.is_a?(Hash)
115
151
  RuleSet.mapping(entry, ['path'], "Replacement import in #{path}")
116
152
  entry = entry['path']
@@ -121,9 +157,14 @@ module AsciidocPubkit
121
157
  collect(File.expand_path(entry, File.dirname(path)), active + [path], visited)
122
158
  end
123
159
  visited[path] = true
124
- imported + rules
160
+ imported + rules.each_with_index.map { |rule, index| { value: rule, path: path, node: item(field(root, 'rules'), index) } }
161
+ rescue ReplacementRuleError
162
+ raise
125
163
  rescue Psych::Exception => e
126
- raise Error, "Invalid replacement YAML in #{path}: #{e.message}"
164
+ raise ReplacementRuleError.new(path, location, "Invalid replacement YAML in #{path}: #{e.message}",
165
+ line: e.respond_to?(:line) ? e.line : nil)
166
+ rescue Error, SystemCallError => e
167
+ raise ReplacementRuleError.new(path, location, e.message)
127
168
  end
128
169
 
129
170
  def self.expand(template, match)
@@ -138,16 +179,23 @@ module AsciidocPubkit
138
179
  end
139
180
  end
140
181
 
141
- def self.matches(text, patterns, expected)
142
- patterns.flat_map do |regex|
143
- text.to_enum(:scan, regex).map do
144
- match = Regexp.last_match
145
- raise Error, 'Zero-length replacement matches are unsupported.' if match.begin(0) == match.end(0)
146
- { start: match.begin(0), finish: match.end(0), before: match[0], after: expand(expected, match) }
182
+ def self.matches(text, patterns, expected, source: nil)
183
+ patterns.each_with_index.flat_map do |regex, index|
184
+ begin
185
+ text.to_enum(:scan, regex).map do
186
+ match = Regexp.last_match
187
+ raise Error, 'Zero-length replacement matches are unsupported.' if match.begin(0) == match.end(0)
188
+ { start: match.begin(0), finish: match.end(0), before: match[0], after: expand(expected, match) }
189
+ end
190
+ rescue Error, Regexp::TimeoutError => e
191
+ message = e.is_a?(Regexp::TimeoutError) ? 'Replacement regex timed out.' : e.message
192
+ if source
193
+ location = message.start_with?('Unsupported replacement reference:') ? field(source[:node], 'expected') : item(source[:pattern_node], index)
194
+ raise ReplacementRuleError.new(source[:path], location, message)
195
+ end
196
+ raise Error, message
147
197
  end
148
198
  end.reject { |edit| edit[:before] == edit[:after] }.uniq.sort_by { |edit| edit[:start] }
149
- rescue Regexp::TimeoutError
150
- raise Error, 'Replacement regex timed out.'
151
199
  end
152
200
 
153
201
  def self.reject_overlaps!(edits)
@@ -181,7 +229,7 @@ module AsciidocPubkit
181
229
  line_offsets = [0]
182
230
  lines.each { |line| line_offsets << line_offsets.last + line.length }
183
231
  rules.each do |rule|
184
- ReplacementRules.matches(text, rule[:patterns], rule[:expected]).each do |edit|
232
+ ReplacementRules.matches(text, rule[:patterns], rule[:expected], source: rule).each do |edit|
185
233
  next if protected_ranges.any? { |range| edit[:start] < range.end && edit[:finish] > range.begin }
186
234
  # Keep physical line boundaries intact, including CRLF.
187
235
  raise Error, 'Replacement matches and results must stay on one line.' if (edit[:before] + edit[:after]).match?(/[\r\n]/)
@@ -9,7 +9,7 @@ require 'pathname'
9
9
  require 'optparse'
10
10
 
11
11
  module AsciidocPubkit
12
- VERSION = '0.8.1'
12
+ VERSION = '0.8.2'
13
13
  class Error < StandardError; end
14
14
 
15
15
  def self.hash_text(text)
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.8.1
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - CyberGarage