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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +20 -7
- data/lib/asciidoc_pubkit/replacement.rb +107 -59
- data/lib/asciidoc_pubkit.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dd09ef810a52e64bd286b34f4f59cda2cf357214abd94a1255ee44d77a96e27
|
|
4
|
+
data.tar.gz: fec7d11ca4f985b41aa1d2e5d37c8b126d56a1dc1ee86874330d2239634207c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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]/)
|
data/lib/asciidoc_pubkit.rb
CHANGED