asciidoc-pubkit 0.8.0 → 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: 5e6439ccb97f49f7e31290cb8d60b1f361cf444889ac0631cdd1f9e28f8a0d9c
4
- data.tar.gz: 484ff3e7ac4475ea693ff7d5751bc34e1537b66dda90bb7d425bb55acb467322
3
+ metadata.gz: 3dd09ef810a52e64bd286b34f4f59cda2cf357214abd94a1255ee44d77a96e27
4
+ data.tar.gz: fec7d11ca4f985b41aa1d2e5d37c8b126d56a1dc1ee86874330d2239634207c1
5
5
  SHA512:
6
- metadata.gz: 9ccf0130e06770e7b12774385daab670fffde6a128e83af1d67300ada91ea6c15fc281645cfae6da6b7b009cca36d01429287ee36461c11f2d43c439d8307fe5
7
- data.tar.gz: f70b717d0633729647ac733670604e3d6ace15354efc31167f8af8994b62531ddba1c74f6456c293ccf16bbee4833ddbd2efb655c520151edee6b6559d94ca0c
6
+ metadata.gz: e56e14f6abd189a621a3f4b918d9e24697749bc1a777816b2cdfa9a02bfc662f1b53e831e291af99b5254ac976a6fa72a2703c224d98ce979e0d9d7eac5be6d5
7
+ data.tar.gz: 19361ba968e9738a736f4452eb6590b90cbc1ca998e128d0cdd952f1da527c66d97817f6913f9957092c8dfbae55b5019e90909e1709337671bb57384031da06
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
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
+
10
+ ## 0.8.1 — 2026-10-01
11
+
12
+ - Add an opening README feature table and describe prose replacements before
13
+ scoring and review.
14
+
15
+ - Support replacement regex `/i` with Ruby case-insensitive matching and translate
16
+ `\b`/`\B` assertions to limited ECMAScript Unicode word boundaries. Preserve
17
+ capture numbering, source positions, protected spans, and backspace-in-class
18
+ semantics; retain Ruby behavior elsewhere and reject other flags.
19
+
20
+ - Treat null replacement `rules`, including bare `rules:`, as an empty local
21
+ rule set while retaining imported rules and rejecting other non-array values.
22
+
23
+ - Accept nonempty string arrays in replacement `pattern` as an alias for
24
+ `patterns`, retaining strict validation, specs, protection, and conflict checks.
25
+
26
+ - Document omitted replacement `rules` as an empty set, including version-only
27
+ files, and verify CLI no-op behavior and application through import-only chains.
28
+
29
+ - Support nested local `imports` in replacement rules, resolving paths relative
30
+ to each importing file and accepting import-only files and `path` mappings.
31
+ Deduplicate physical files, reject cycles and unsupported import options, and
32
+ preserve validation, conflict detection, and prose-only replacement scope.
33
+
3
34
  ## 0.8.0 — 2026-10-01
4
35
 
5
36
  - Add explicit `replace check`, `replace diff`, and `replace apply` commands for
data/README.md CHANGED
@@ -7,13 +7,34 @@
7
7
 
8
8
  A toolkit for authoring and reviewing AsciiDoc books.
9
9
 
10
- Version 0.8.0 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.
14
14
  The CLI, diagnostics, documentation, and generated instructions are in English.
15
15
  Japanese text is retained in manuscript excerpts, rule dictionaries, and fixtures.
16
16
 
17
+ ## Available features
18
+
19
+ The following commands are available in version 0.8.2.
20
+
21
+ | Feature | Commands | Behavior |
22
+ | --- | --- | --- |
23
+ | [Writing criteria and prompts](#writing-commands) | `writing criteria`, `writing prompt` | Print Japanese prose criteria or generate a writing prompt without a manuscript or MeCab |
24
+ | [Replacement preview](#prose-replacements) | `replace check`, `replace diff` | Report mechanical replacement candidates or preview a diff for mapped running prose; preserve source files |
25
+ | [Replacement application](#prose-replacements) | `replace apply` | Apply author-supplied rules after checking protected content and document structure; no MeCab or AI required |
26
+ | [Prose review](#review-workflow) | `review scan` | Collect mapped Japanese running prose and contextual review candidates with MeCab/IPADIC by default |
27
+ | [Heading review](#heading-review) | `review scan --scope headings` | Collect editable section titles separately, retaining the outline and read-only body evidence |
28
+ | [Review prompts](#prompt) | `review prompt` | Generate one Markdown prompt from a saved session for review and editing by an external reviewer |
29
+ | [Baseline verification](#verify) | `review verify` | Check edited sources against a saved baseline for mechanical preservation; meaning is not verified |
30
+ | [Manuscript scoring](#score-an-asciidoc-manuscript) | `review score` | Report a prose candidate-density score; explicit `--agent codex` or `--agent claude` optionally invokes a local AI CLI for readability ratings |
31
+
32
+ Version 0.8.1 additionally supports nested replacement-rule imports,
33
+ omitted/null `rules`, arrays in `pattern`, `/i`, and limited ECMAScript word
34
+ boundaries. See [Prose replacements](#prose-replacements) for supported syntax
35
+ and preservation limits. Only `replace apply` edits manuscripts directly;
36
+ review prompts supply instructions for an external reviewer.
37
+
17
38
  ## Status
18
39
 
19
40
  Version 0.6.3 adds separate heading review with `review scan --scope headings`.
@@ -86,7 +107,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
86
107
 
87
108
  ```ruby
88
109
  source 'https://rubygems.org'
89
- gem 'asciidoc-pubkit', '~> 0.8.0'
110
+ gem 'asciidoc-pubkit', '~> 0.8.2'
90
111
  ```
91
112
 
92
113
  Then install dependencies and run the CLI through Bundler:
@@ -108,7 +129,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
108
129
  cd asciidoc-pubkit
109
130
  bundle install
110
131
  gem build asciidoc-pubkit.gemspec
111
- gem install ./asciidoc-pubkit-0.8.0.gem
132
+ gem install ./asciidoc-pubkit-0.8.2.gem
112
133
  asciidoc-pubkit --version
113
134
  ```
114
135
 
@@ -132,12 +153,205 @@ asciidoc-pubkit writing prompt --lang ja --output writing-prompt.md
132
153
  ```
133
154
 
134
155
  For example, `--lang en` exits with an unsupported-language error. No English
135
- review or writing criteria are shipped in 0.8.0.
156
+ review or writing criteria are shipped in 0.8.2.
136
157
 
137
158
  For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
138
159
  paragraphs deliberately contain review candidates; its code block must remain
139
160
  unchanged.
140
161
 
162
+ ## Prose replacements
163
+
164
+ Available starting with version 0.8.0.
165
+ It applies author-supplied mechanical replacement rules without MeCab or an AI
166
+ CLI. Review candidates and glossary variants remain advisory and are never
167
+ implicitly applied. Replacement rules are separate from review rules.
168
+
169
+ ```sh
170
+ asciidoc-pubkit replace check book.adoc --rules replacements.yml
171
+ asciidoc-pubkit replace diff book.adoc --rules replacements.yml
172
+ asciidoc-pubkit replace apply book.adoc --rules replacements.yml
173
+ ```
174
+
175
+ `check` reports original text, proposed text, rule IDs (`rule-1`, etc.), source
176
+ paths, one-based Unicode lines/columns, and a coverage-notice count. Its exit code
177
+ is 1 when replacements are available, 0 otherwise. `diff` previews unified diffs
178
+ without editing; it requires the external `diff` command. `apply` explicitly
179
+ authorizes source replacement without prompting. Successful diff/apply commands
180
+ return 0; invalid rules, conflicts, unsafe edits, and input/output failures return
181
+ 2. Check/diff accept `--output` for a new file only; apply rejects `--output`.
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
+
196
+ All three accept `--lang ja`, `--base-dir`, `--config`, and `--only` (an included
197
+ source file). Normal configuration discovery and `review.base_dir`, `language`,
198
+ `attributes`, and `exclude` control parsing and selection. `--rules` is required;
199
+ `review.rules` and glossary entries do not supply replacement rules. There are
200
+ no built-in replacement rules and no automatic search for `prh.yml`.
201
+
202
+ ### Limited prh-format rules
203
+
204
+ The supported format is a strict subset of [prh](https://github.com/prh/prh):
205
+
206
+ ```yaml
207
+ version: 1
208
+ rules:
209
+ - expected: '®'
210
+ pattern: '®'
211
+ - expected: '($1)'
212
+ pattern: '/(([^()\r\n]+))/'
213
+ specs:
214
+ - from: '設定(任意)'
215
+ to: '設定(任意)'
216
+ - expected: '/'
217
+ patterns:
218
+ - '/'
219
+ ```
220
+
221
+ The root requires `version: 1` and accepts optional `rules` and `imports` arrays.
222
+ Starting with version 0.8.1, omitted arrays default to empty. Version 0.8.0
223
+ requires `rules` and rejects `imports`. Each rule requires a string
224
+ `expected` (empty strings permit deletion) and exactly one of `pattern` (a
225
+ nonempty string or, starting with version 0.8.1, a nonempty array of nonempty
226
+ strings) or `patterns` (a nonempty array of nonempty strings). Array-valued
227
+ `pattern` is equivalent to `patterns`; specifying both keys is rejected. Optional
228
+ `specs` is an array of exact `from`/`to` string pairs, validated on load. Specs
229
+ exercise the rule on plain text, not AsciiDoc selection or inline protection.
230
+
231
+ Strings not beginning with `/` match literally. `/.../` denotes a regex; the
232
+ version 0.8.1 also accepts `/.../i` for Ruby case-insensitive matching.
233
+ Version 0.8.0 rejects all flags. All occurrences are collected. The implementation
234
+ uses Ruby regexes with
235
+ a timeout and accepts a limited common syntax: character classes, ordinary
236
+ captures, noncapturing groups, lookarounds, alternatives, anchors, quantifiers,
237
+ and the escapes `\n`, `\r`, `\t`, `\d`, `\D`, `\s`, `\S`, `\w`, `\W`
238
+ and escaped punctuation. Ruby regex character-class behavior applies; this is
239
+ not a JavaScript regex engine or a promise of full prh equivalence. Engine-specific
240
+ groups/escapes and flags other than a single `i` are rejected. A leading literal slash must be expressed
241
+ as an escaped regex, for example `/\//`. Zero-length matches are rejected.
242
+
243
+ Replacement strings support `$1` through `$99` for existing capture groups and
244
+ `$$` for a literal dollar sign. Unsupported dollar references are rejected when
245
+ matched. Missing optional captures expand to empty strings. YAML single quotes
246
+ are recommended to keep backslashes literal. Unknown fields, including
247
+ `options` and `regexpMustEmpty`, and omitted patterns are rejected rather than
248
+ ignored. prh's automatic pattern generation is not supported.
249
+
250
+ For example, version 0.8.1 also accepts:
251
+
252
+ ```yaml
253
+ version: 1
254
+ rules:
255
+ - expected: 'ハードウェア'
256
+ pattern:
257
+ - 'ハードウエア'
258
+ - 'ハードウェアー'
259
+ ```
260
+
261
+ Each array entry uses the same literal/regex syntax and validation as a single
262
+ pattern. Arrays do not change supported flags or overlap handling.
263
+
264
+ ### Limited ECMAScript boundary compatibility
265
+
266
+ Version 0.8.1 translates `\b` and `\B` outside character classes into Ruby
267
+ lookarounds with ECMAScript word-character semantics, following prh's default
268
+ Unicode mode. Word characters are ASCII letters, digits, and underscore; Japanese
269
+ characters are non-word characters. Thus `/\bTips\b/` matches `Tips` in
270
+ `前Tips後`, but not in `Tips2`, `_Tips`, or `ATips`.
271
+ With `/i`, long s (`ſ`) and Kelvin sign (`K`) also count as word characters under
272
+ ECMAScript Unicode case folding. The generated assertions add no capture groups
273
+ and preserve original match offsets. Inside a character class, `\b` means a
274
+ backspace character; `\B` there is rejected. Escaped literal backslashes are
275
+ preserved.
276
+
277
+ Only the boundary assertions emulate ECMAScript. `/i` uses Ruby's
278
+ `Regexp::IGNORECASE`; Unicode case folding can differ from JavaScript (for
279
+ example multi-character folds). Other constructs, including whitespace classes,
280
+ retain Ruby semantics. This is limited prh compatibility, not full ECMAScript
281
+ conformance. Flags `g`, `m`, `s`, `u`, `y`, `d`, and `v` and duplicate `i` are
282
+ rejected; all occurrences are already collected independently of `g`.
283
+
284
+ ### Importing replacement rules
285
+
286
+ Version 0.8.1 supports nested `imports`; version 0.8.0 does not. For example:
287
+
288
+ ```yaml
289
+ version: 1
290
+ imports:
291
+ - ../../rules/prh.yml
292
+ - path: ./terminology.yml
293
+ rules:
294
+ - expected: '®'
295
+ pattern: '®'
296
+ ```
297
+
298
+ Each entry is a nonempty local file path string or a mapping containing exactly
299
+ `path`. Relative paths resolve from the importing YAML file, including nested
300
+ imports, independently of the working directory and manuscript base directory.
301
+ Absolute local paths are also accepted; URL imports are rejected. An import-only
302
+ file may omit `rules`. A file containing only `version: 1` is also valid and
303
+ contributes no rules. With no imported or local rules, `check`, `diff`, and
304
+ `apply` succeed without changing manuscript sources, and `diff` emits no patch.
305
+ Starting with version 0.8.1, a bare `rules:`, `rules: null`, and `rules: ~`
306
+ also contribute no local rules, just like omission or `rules: []`; imported rules
307
+ are still loaded. Other non-array values remain invalid. This normalization
308
+ applies only to `rules`, not to `imports` or individual rule fields.
309
+ Every imported file must use version 1 and pass the same
310
+ strict rule validation and specs as the entry file.
311
+
312
+ Imports are loaded in listed order, recursively before each file's own rules.
313
+ Each physical file is loaded once per plan, including repeated paths, symlink
314
+ aliases, and shared dependencies. Rule IDs are assigned across the flattened
315
+ set. Cycles (including symlink aliases), missing files, and import chains deeper
316
+ than 100 files fail rather than silently omitting rules. Imported and local rules
317
+ are combined, not overridden: overlapping replacement candidates remain errors
318
+ and replacements are still calculated only against the original text.
319
+
320
+ Import options such as `ignoreRules` and `disableImports` remain unsupported and
321
+ are rejected. Source selection and protection are unchanged by imports.
322
+
323
+ ### Selection and preservation
324
+
325
+ Only unambiguously source-mapped running prose in the existing default review
326
+ scope is eligible. Headings, lists, tables, quotations, listing/source, literal,
327
+ and passthrough blocks are excluded. Recognized inline code, quotations, URLs,
328
+ macros, and attribute references are protected by the existing conservative
329
+ inline heuristic, not a complete inline parser. A match overlapping any protected
330
+ span is skipped; matching is performed on the original text, never on blanked
331
+ placeholders. Backtick-to-bold and list-marker conversions are outside this scope.
332
+
333
+ Candidates are calculated once against the original sources; replacement results
334
+ are not searched again. Overlapping edits are conflicts, not resolved by rule
335
+ order. Matches/results containing line breaks are rejected. Source files retain
336
+ UTF-8 bytes, original line endings, and unrelated content; repeated include
337
+ occurrences must not create overlapping edits.
338
+
339
+ Before presenting a plan, the complete captured source set is copied to temporary
340
+ staging and reparsed. Parsing diagnostics, structural changes, new/deleted protected
341
+ inline tokens, and protected-content/source-membership changes reject the plan.
342
+ Relative includes within the base directory are supported. Absolute includes and
343
+ includes whose paths depend on the original filesystem may fail staged validation;
344
+ they are not rewritten or silently accepted. Symlink entrypoints are rejected.
345
+
346
+ Apply stages all changed files beside their destinations, checks all captured
347
+ source bytes against the plan, and renames the prepared files into place while
348
+ preserving permission bits. An ordinary installation error restores sources
349
+ already installed. This is not a crash-atomic transaction across multiple files;
350
+ keep manuscripts under version control and avoid concurrent edits during apply.
351
+ No review session is created or replaced. Existing sessions retain their original
352
+ baselines; applying replacements can make a session stale for prompt generation.
353
+ Mechanical validation does not establish semantic correctness.
354
+
141
355
  ## Score an AsciiDoc manuscript
142
356
 
143
357
  Pass any AsciiDoc entry file directly; no review session or gold annotations are
@@ -753,107 +967,6 @@ Regex patterns are not interpreted in glossary or allow-list entries. Unknown
753
967
  configuration keys are rejected. A bare `mecab_command` is resolved through PATH;
754
968
  use an absolute path for an explicit executable override.
755
969
 
756
- ## Prose replacements
757
-
758
- Available starting with version 0.8.0.
759
- It applies author-supplied mechanical replacement rules without MeCab or an AI
760
- CLI. Review candidates and glossary variants remain advisory and are never
761
- implicitly applied. Replacement rules are separate from review rules.
762
-
763
- ```sh
764
- asciidoc-pubkit replace check book.adoc --rules replacements.yml
765
- asciidoc-pubkit replace diff book.adoc --rules replacements.yml
766
- asciidoc-pubkit replace apply book.adoc --rules replacements.yml
767
- ```
768
-
769
- `check` reports original text, proposed text, rule IDs (`rule-1`, etc.), source
770
- paths, one-based Unicode lines/columns, and a coverage-notice count. Its exit code
771
- is 1 when replacements are available, 0 otherwise. `diff` previews unified diffs
772
- without editing; it requires the external `diff` command. `apply` explicitly
773
- authorizes source replacement without prompting. Successful diff/apply commands
774
- return 0; invalid rules, conflicts, unsafe edits, and input/output failures return
775
- 2. Check/diff accept `--output` for a new file only; apply rejects `--output`.
776
-
777
- All three accept `--lang ja`, `--base-dir`, `--config`, and `--only` (an included
778
- source file). Normal configuration discovery and `review.base_dir`, `language`,
779
- `attributes`, and `exclude` control parsing and selection. `--rules` is required;
780
- `review.rules` and glossary entries do not supply replacement rules. There are
781
- no built-in replacement rules and no automatic search for `prh.yml`.
782
-
783
- ### Limited prh-format rules
784
-
785
- The supported format is a strict subset of [prh](https://github.com/prh/prh):
786
-
787
- ```yaml
788
- version: 1
789
- rules:
790
- - expected: '®'
791
- pattern: '®'
792
- - expected: '($1)'
793
- pattern: '/(([^()\r\n]+))/'
794
- specs:
795
- - from: '設定(任意)'
796
- to: '設定(任意)'
797
- - expected: '/'
798
- patterns:
799
- - '/'
800
- ```
801
-
802
- The root contains exactly `version: 1` and `rules`. Each rule requires a string
803
- `expected` (empty strings permit deletion) and exactly one of `pattern` (a
804
- nonempty string) or `patterns` (a nonempty array of nonempty strings). Optional
805
- `specs` is an array of exact `from`/`to` string pairs, validated on load. Specs
806
- exercise the rule on plain text, not AsciiDoc selection or inline protection.
807
-
808
- Strings not beginning with `/` match literally. `/.../` denotes a regex with no
809
- flags; all occurrences are collected. The implementation uses Ruby regexes with
810
- a timeout and accepts a limited common syntax: character classes, ordinary
811
- captures, noncapturing groups, lookarounds, alternatives, anchors, quantifiers,
812
- and the escapes `\n`, `\r`, `\t`, `\d`, `\D`, `\s`, `\S`, `\w`, `\W`
813
- and escaped punctuation. Ruby regex character-class behavior applies; this is
814
- not a JavaScript regex engine or a promise of full prh equivalence. Engine-specific
815
- groups/escapes and flags are rejected. A leading literal slash must be expressed
816
- as an escaped regex, for example `/\//`. Zero-length matches are rejected.
817
-
818
- Replacement strings support `$1` through `$99` for existing capture groups and
819
- `$$` for a literal dollar sign. Unsupported dollar references are rejected when
820
- matched. Missing optional captures expand to empty strings. YAML single quotes
821
- are recommended to keep backslashes literal. Unknown fields, including `imports`,
822
- `options`, and `regexpMustEmpty`, and omitted patterns are rejected rather than
823
- ignored. Regex flags and prh's automatic pattern generation are not supported.
824
-
825
- ### Selection and preservation
826
-
827
- Only unambiguously source-mapped running prose in the existing default review
828
- scope is eligible. Headings, lists, tables, quotations, listing/source, literal,
829
- and passthrough blocks are excluded. Recognized inline code, quotations, URLs,
830
- macros, and attribute references are protected by the existing conservative
831
- inline heuristic, not a complete inline parser. A match overlapping any protected
832
- span is skipped; matching is performed on the original text, never on blanked
833
- placeholders. Backtick-to-bold and list-marker conversions are outside this scope.
834
-
835
- Candidates are calculated once against the original sources; replacement results
836
- are not searched again. Overlapping edits are conflicts, not resolved by rule
837
- order. Matches/results containing line breaks are rejected. Source files retain
838
- UTF-8 bytes, original line endings, and unrelated content; repeated include
839
- occurrences must not create overlapping edits.
840
-
841
- Before presenting a plan, the complete captured source set is copied to temporary
842
- staging and reparsed. Parsing diagnostics, structural changes, new/deleted protected
843
- inline tokens, and protected-content/source-membership changes reject the plan.
844
- Relative includes within the base directory are supported. Absolute includes and
845
- includes whose paths depend on the original filesystem may fail staged validation;
846
- they are not rewritten or silently accepted. Symlink entrypoints are rejected.
847
-
848
- Apply stages all changed files beside their destinations, checks all captured
849
- source bytes against the plan, and renames the prepared files into place while
850
- preserving permission bits. An ordinary installation error restores sources
851
- already installed. This is not a crash-atomic transaction across multiple files;
852
- keep manuscripts under version control and avoid concurrent edits during apply.
853
- No review session is created or replaced. Existing sessions retain their original
854
- baselines; applying replacements can make a session stale for prompt generation.
855
- Mechanical validation does not establish semantic correctness.
856
-
857
970
  ## Rules and coverage
858
971
 
859
972
  | Rule | Severity | Purpose |
@@ -944,12 +1057,12 @@ reports analyzer changes instead of treating results from different dictionaries
944
1057
  as directly comparable. Prompt generation uses saved evidence and does not need
945
1058
  MeCab. Changed rules or dictionary settings require a new scan.
946
1059
 
947
- Sessions from earlier tool versions are not compatible with 0.8.0 (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
948
1061
  an ongoing review and finish it with the original version, or start a new review
949
1062
  pass in a different directory:
950
1063
 
951
1064
  ```sh
952
- asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.0
1065
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.2
953
1066
  ```
954
1067
 
955
1068
  Severity describes review priority, not proof of an error. There is no AI-authorship
@@ -5,55 +5,166 @@ 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
- data = YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false)
11
- RuleSet.mapping(data, %w[version rules], 'Replacement rules')
12
- raise Error, 'Replacement version must be integer 1.' unless data['version'].is_a?(Integer) && data['version'] == 1
13
- raise Error, 'rules must be an array.' unless data['rules'].is_a?(Array)
14
- data['rules'].each_with_index.map do |rule, index|
15
- unless rule.is_a?(Hash) && (rule.keys - %w[expected pattern patterns specs]).empty? &&
16
- rule['expected'].is_a?(String) && (rule.key?('pattern') ^ rule.key?('patterns'))
17
- raise Error, "Rule #{index + 1} requires expected and exactly one of pattern or patterns; unsupported keys are rejected."
18
- end
19
- patterns = rule.key?('pattern') ? [rule['pattern']] : rule['patterns']
20
- unless patterns.is_a?(Array) && !patterns.empty? && patterns.all? { |p| p.is_a?(String) && !p.empty? }
21
- raise Error, 'Replacement patterns must be nonempty strings.'
22
- end
23
- compiled = patterns.map do |pattern|
24
- if pattern.start_with?('/')
25
- match = pattern.match(%r{\A/(.*)/([a-z]*)\z}m)
26
- raise Error, 'Regex flags are unsupported; omit flags (all matches are collected).' unless match && match[2].empty?
27
- body = match[1]
28
- # Deliberately small common JavaScript/Ruby subset; Ruby-only escapes
29
- # and engine-specific groups must not silently change JS semantics.
30
- if body.match?(/\\[^\\\/.*+?()\[\]{}^$|nrtdDsSwW-]/) || body.match?(/\(\?(?![:=!]|<[=!])/) || body.include?('&&')
31
- raise Error, 'Unsupported regex syntax in replacement rule.'
32
- end
33
- Regexp.new(body.gsub('\\/', '/'), timeout: 0.1)
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.'
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 }
80
+ rescue RegexpError, Regexp::TimeoutError => e
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)
84
+ end
85
+
86
+ # prh uses Unicode JS regexes: word characters are ASCII letters/digits/_;
87
+ # Unicode ignore-case additionally folds long s and Kelvin sign to ASCII.
88
+ # Scope out Ruby's ignore-case so these assertion classes stay exact.
89
+ def self.javascript_boundaries(body, ignore_case:)
90
+ word = ignore_case ? '(?-i:[A-Za-z0-9_ſK])' : '[A-Za-z0-9_]'
91
+ boundary = "(?:(?<=#{word})(?!#{word})|(?<!#{word})(?=#{word}))"
92
+ nonboundary = "(?:(?<=#{word})(?=#{word})|(?<!#{word})(?!#{word}))"
93
+ chars = body.chars
94
+ result = +''
95
+ in_class = false
96
+ index = 0
97
+ while index < chars.length
98
+ char = chars[index]
99
+ if char == '\\' && index + 1 < chars.length
100
+ following = chars[index + 1]
101
+ if following == 'b'
102
+ result << (in_class ? '\x08' : boundary)
103
+ elsif following == 'B'
104
+ raise Error, '\B inside a character class is unsupported.' if in_class
105
+ result << nonboundary
34
106
  else
35
- Regexp.new(Regexp.escape(pattern), timeout: 0.1)
107
+ result << char << following
36
108
  end
109
+ index += 2
110
+ next
37
111
  end
38
- compiled.each do |regex|
39
- regex.match('') # Detect common empty matches; runtime checks cover contextual ones.
40
- raise Error, 'Zero-length replacement matches are unsupported.' if regex.match('')
112
+ in_class = true if char == '['
113
+ in_class = false if char == ']'
114
+ result << char
115
+ index += 1
116
+ end
117
+ result
118
+ end
119
+
120
+ def self.collect(path, active, visited)
121
+ path = File.expand_path(path)
122
+ location = nil
123
+ path = File.realpath(path)
124
+ if active.include?(path)
125
+ raise Error, "Circular replacement imports: #{(active + [path]).join(' -> ')}"
126
+ end
127
+ return [] if visited[path]
128
+ raise Error, 'Replacement imports exceed the maximum depth of 100 files.' if active.length >= 100
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)
134
+ unless data.is_a?(Hash) && data.key?('version') && (data.keys - %w[version rules imports]).empty?
135
+ raise Error, "Replacement rules in #{path} require version and accept only rules and imports."
136
+ end
137
+ location = field(root, 'version')
138
+ unless data['version'].is_a?(Integer) && data['version'] == 1
139
+ raise Error, "Replacement version in #{path} must be integer 1."
140
+ end
141
+ rules = data.fetch('rules', [])
142
+ rules = [] if rules.nil?
143
+ imports = data.fetch('imports', [])
144
+ location = field(root, 'rules')
145
+ raise Error, "rules in #{path} must be an array." unless rules.is_a?(Array)
146
+ location = field(root, 'imports')
147
+ raise Error, "imports in #{path} must be an array." unless imports.is_a?(Array)
148
+ imported = imports.each_with_index.flat_map do |entry, index|
149
+ location = item(field(root, 'imports'), index)
150
+ if entry.is_a?(Hash)
151
+ RuleSet.mapping(entry, ['path'], "Replacement import in #{path}")
152
+ entry = entry['path']
41
153
  end
42
- specs = rule.fetch('specs', [])
43
- raise Error, 'specs must be an array.' unless specs.is_a?(Array)
44
- specs.each do |spec|
45
- RuleSet.mapping(spec, %w[from to], 'Replacement spec')
46
- raise Error, 'Spec from/to must be strings.' unless spec.values.all? { |v| v.is_a?(String) }
47
- result = spec['from'].dup
48
- edits = matches(spec['from'], compiled, rule['expected'])
49
- reject_overlaps!(edits)
50
- edits.reverse_each { |edit| result[edit[:start]...edit[:finish]] = edit[:after] }
51
- raise Error, "Replacement spec failed for rule #{index + 1}." unless result == spec['to']
154
+ unless entry.is_a?(String) && !entry.strip.empty? && !entry.match?(/\A[a-z][a-z0-9+.-]*:/i)
155
+ raise Error, "Replacement imports in #{path} must specify nonempty local file paths."
52
156
  end
53
- { id: "rule-#{index + 1}", patterns: compiled, expected: rule['expected'] }
157
+ collect(File.expand_path(entry, File.dirname(path)), active + [path], visited)
54
158
  end
55
- rescue RegexpError, Regexp::TimeoutError => e
56
- raise Error, "Invalid replacement regex: #{e.message}"
159
+ visited[path] = true
160
+ imported + rules.each_with_index.map { |rule, index| { value: rule, path: path, node: item(field(root, 'rules'), index) } }
161
+ rescue ReplacementRuleError
162
+ raise
163
+ rescue Psych::Exception => e
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)
57
168
  end
58
169
 
59
170
  def self.expand(template, match)
@@ -68,16 +179,23 @@ module AsciidocPubkit
68
179
  end
69
180
  end
70
181
 
71
- def self.matches(text, patterns, expected)
72
- patterns.flat_map do |regex|
73
- text.to_enum(:scan, regex).map do
74
- match = Regexp.last_match
75
- raise Error, 'Zero-length replacement matches are unsupported.' if match.begin(0) == match.end(0)
76
- { 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
77
197
  end
78
198
  end.reject { |edit| edit[:before] == edit[:after] }.uniq.sort_by { |edit| edit[:start] }
79
- rescue Regexp::TimeoutError
80
- raise Error, 'Replacement regex timed out.'
81
199
  end
82
200
 
83
201
  def self.reject_overlaps!(edits)
@@ -111,7 +229,7 @@ module AsciidocPubkit
111
229
  line_offsets = [0]
112
230
  lines.each { |line| line_offsets << line_offsets.last + line.length }
113
231
  rules.each do |rule|
114
- ReplacementRules.matches(text, rule[:patterns], rule[:expected]).each do |edit|
232
+ ReplacementRules.matches(text, rule[:patterns], rule[:expected], source: rule).each do |edit|
115
233
  next if protected_ranges.any? { |range| edit[:start] < range.end && edit[:finish] > range.begin }
116
234
  # Keep physical line boundaries intact, including CRLF.
117
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.0'
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.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - CyberGarage