makiri 0.7.0 → 0.9.0
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/.github/workflows/security.yml +49 -0
- data/.github/workflows/valgrind.yml +12 -1
- data/CHANGELOG.md +66 -0
- data/README.md +51 -3
- data/Rakefile +91 -2
- data/ext/makiri/dom_adapter/cross_import.c +27 -5
- data/ext/makiri/glue/glue.h +1 -0
- data/ext/makiri/glue/ruby_doc.c +14 -0
- data/ext/makiri/glue/ruby_html_mutate.c +85 -28
- data/ext/makiri/glue/ruby_html_node.c +81 -0
- data/ext/makiri/glue/ruby_xml_node.c +401 -18
- data/ext/makiri/xml/mkr_xml_mutate.c +65 -28
- data/ext/makiri/xml/mkr_xml_node.h +11 -0
- data/ext/makiri/xml/mkr_xml_tree.c +5 -0
- data/ext/makiri/xpath/mkr_xpath_eval_body.h +39 -4
- data/lib/makiri/version.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: ece0ed1298dbb761fbaeecc7e497b20ee69766c988ef5e0c671e282a3a455de9
|
|
4
|
+
data.tar.gz: a909b7cbd715ad23e58f5c4f649298c6ca4472c9004423eb73930baadea205ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 265f7671dd689039fb98a41513b45fa2b3dae542f9a99942832fe25e36c1b4e7430caf8c14f39c3832947819258a850cacca556ef278785356d616d5b132cd16
|
|
7
|
+
data.tar.gz: 7bed0126338689b1e5c2bbf47b60d9550352b471162f80b030cad11141158efbd7a293f48551be73bbdfe1ac9d24cab6c0074ff46ef5d01e2aa404888e147c0e
|
|
@@ -157,6 +157,55 @@ jobs:
|
|
|
157
157
|
# poisoning on), catching intra-arena overflows that a plain ASan build cannot
|
|
158
158
|
# see - the class the v3.0.0 :lexbor-contains overflow belonged to. Heavy
|
|
159
159
|
# (full instrumented Lexbor rebuild), so nightly only.
|
|
160
|
+
# The randomised property checks (spec/invariants/). Cheap enough to gate a PR
|
|
161
|
+
# on, so they run on every push; the ASan pass is nightly because an
|
|
162
|
+
# instrumented build is slow.
|
|
163
|
+
invariants:
|
|
164
|
+
name: Invariant checks
|
|
165
|
+
runs-on: ubuntu-latest
|
|
166
|
+
if: github.event_name != 'schedule'
|
|
167
|
+
steps:
|
|
168
|
+
- name: Checkout (with vendored Lexbor submodule)
|
|
169
|
+
uses: actions/checkout@v6
|
|
170
|
+
with:
|
|
171
|
+
submodules: recursive
|
|
172
|
+
|
|
173
|
+
- name: Ensure cmake is available
|
|
174
|
+
uses: lukka/get-cmake@latest
|
|
175
|
+
|
|
176
|
+
- name: Set up Ruby
|
|
177
|
+
uses: ruby/setup-ruby@v1
|
|
178
|
+
with:
|
|
179
|
+
ruby-version: "3.4"
|
|
180
|
+
bundler-cache: true
|
|
181
|
+
|
|
182
|
+
- name: Run the invariant checks
|
|
183
|
+
run: bundle exec rake invariants
|
|
184
|
+
|
|
185
|
+
invariants-sanitize:
|
|
186
|
+
name: Nightly invariant checks under ASan + UBSan
|
|
187
|
+
runs-on: ubuntu-latest
|
|
188
|
+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
189
|
+
steps:
|
|
190
|
+
- name: Checkout (with vendored Lexbor submodule)
|
|
191
|
+
uses: actions/checkout@v6
|
|
192
|
+
with:
|
|
193
|
+
submodules: recursive
|
|
194
|
+
|
|
195
|
+
- name: Ensure cmake is available
|
|
196
|
+
uses: lukka/get-cmake@latest
|
|
197
|
+
|
|
198
|
+
- name: Set up Ruby
|
|
199
|
+
uses: ruby/setup-ruby@v1
|
|
200
|
+
with:
|
|
201
|
+
ruby-version: "3.4"
|
|
202
|
+
bundler-cache: true
|
|
203
|
+
|
|
204
|
+
# The text index hands out borrowed slices, so a stale one is a memory bug
|
|
205
|
+
# as much as a wrong answer - this is the pass that would see it.
|
|
206
|
+
- name: Run the invariant checks under sanitizers
|
|
207
|
+
run: bundle exec rake "invariants:sanitize"
|
|
208
|
+
|
|
160
209
|
security-sanitize-lexbor:
|
|
161
210
|
name: Nightly instrumented-Lexbor ASan suite
|
|
162
211
|
runs-on: ubuntu-latest
|
|
@@ -24,11 +24,16 @@ jobs:
|
|
|
24
24
|
# (intra-arena overflows). Runs on Linux because Valgrind is x86_64/amd64
|
|
25
25
|
# Linux only.
|
|
26
26
|
valgrind-memcheck:
|
|
27
|
-
name: Valgrind memcheck (Ruby ${{ matrix.ruby }})
|
|
27
|
+
name: Valgrind memcheck (Ruby ${{ matrix.ruby }}, shard ${{ matrix.shard }}/4)
|
|
28
28
|
runs-on: ubuntu-latest
|
|
29
29
|
timeout-minutes: 360
|
|
30
30
|
env:
|
|
31
31
|
BUNDLE_WITH: valgrind
|
|
32
|
+
# Shard the suite across the matrix below: 4 parallel jobs each memcheck a
|
|
33
|
+
# quarter of the spec files, so the whole suite finishes in ~1/4 the
|
|
34
|
+
# wall-clock with no loss of coverage (each shard is balanced by file size).
|
|
35
|
+
VALGRIND_SHARDS: "4"
|
|
36
|
+
VALGRIND_SHARD_INDEX: ${{ matrix.shard }}
|
|
32
37
|
# These heavy jobs verify memory discipline (uninit values, intra-arena
|
|
33
38
|
# overflows, use-after-move), not the property space - so the full
|
|
34
39
|
# 300-iteration PBT sweep (already run by the normal CI matrix) is
|
|
@@ -37,10 +42,16 @@ jobs:
|
|
|
37
42
|
# keeping the run tractable.
|
|
38
43
|
PBT_COUNT: "15"
|
|
39
44
|
CSS_PBT_COUNT: "15"
|
|
45
|
+
# --track-origins roughly doubles memcheck's runtime and only adds an origin
|
|
46
|
+
# backtrace to a finding (the finding itself is still reported without it).
|
|
47
|
+
# Drop it on the frequent post-merge push gate to halve its runtime; keep it
|
|
48
|
+
# for the thorough nightly / manual runs, where the backtrace aids triage.
|
|
49
|
+
VALGRIND_TRACK_ORIGINS: ${{ github.event_name == 'push' && 'no' || 'yes' }}
|
|
40
50
|
strategy:
|
|
41
51
|
fail-fast: false
|
|
42
52
|
matrix:
|
|
43
53
|
ruby: ["3.4"]
|
|
54
|
+
shard: [0, 1, 2, 3] # keep in sync with VALGRIND_SHARDS above
|
|
44
55
|
|
|
45
56
|
steps:
|
|
46
57
|
- name: Checkout (with vendored Lexbor submodule)
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
* `Node#attribute_by_qualified_name(name)` and
|
|
8
|
+
`Node#attribute_value_by_qualified_name(name)`: the attribute whose
|
|
9
|
+
**qualified** name is exactly `name` — its node, and its value — or nil.
|
|
10
|
+
`#[]` cannot answer this: it also finds a prefixed attribute by its local
|
|
11
|
+
name (`svg_a["href"]` returns `xlink:href`), and it lower-cases what it looks
|
|
12
|
+
up (`el["DATA-X"]` finds `data-x`). The new match is byte-exact.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
* **`Makiri::XML` follows the DOM namespace model.** A node's namespace URI is
|
|
17
|
+
decided once — by the parser, or by the context it is first inserted into —
|
|
18
|
+
and does not change afterwards; the serializer emits whatever xmlns
|
|
19
|
+
declarations the output needs:
|
|
20
|
+
|
|
21
|
+
- moving a node under an element that binds its prefix to a different URI
|
|
22
|
+
keeps `namespace_uri`, and the output declares the prefix at the moved node;
|
|
23
|
+
- inserting a node whose prefix is bound nowhere in the destination now
|
|
24
|
+
succeeds instead of raising;
|
|
25
|
+
- `import_node` keeps the namespace of what it copied;
|
|
26
|
+
- an element in no namespace stays in no namespace under a default namespace,
|
|
27
|
+
serialized as `xmlns=""`;
|
|
28
|
+
- `#to_xml` on a node below the root declares the prefixes its subtree uses,
|
|
29
|
+
so the output re-parses to the same namespaces standing alone.
|
|
30
|
+
|
|
31
|
+
Nodes from the factories (`create_element` and friends) still take their
|
|
32
|
+
namespace from the context they are first inserted into.
|
|
33
|
+
|
|
34
|
+
* **Inserting a node from another document adopts it.** `add_child` / `before` /
|
|
35
|
+
`after` / `replace` bring the node over and remove it from the document it
|
|
36
|
+
came from, instead of copying it (`Makiri::XML`) or raising (`Makiri::HTML`).
|
|
37
|
+
A spliced fragment is left empty; a rejected insert leaves the source
|
|
38
|
+
document untouched.
|
|
39
|
+
|
|
40
|
+
The node handed back is a **different object** than the one passed in, so use
|
|
41
|
+
the return value afterwards rather than the argument. `Document#import_node`
|
|
42
|
+
is unchanged: it copies and leaves the source alone.
|
|
43
|
+
|
|
44
|
+
* **XML serialization is capped at the nesting depth the parser accepts.**
|
|
45
|
+
`#to_xml` and `#canonicalize` raise `Makiri::Error` past it, rather than
|
|
46
|
+
emitting XML that Makiri could not read back. A deeper tree is still fine to
|
|
47
|
+
hold, walk and query.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
* XPath axes from an **attribute** context node on the XML backend now follow
|
|
52
|
+
XPath 1.0 §2.2: `following-sibling` and `preceding-sibling` are empty, and
|
|
53
|
+
`following` / `preceding` exclude attribute nodes. They used to return the
|
|
54
|
+
element's later attributes.
|
|
55
|
+
|
|
56
|
+
* A rejected insert no longer leaves part of the moved subtree carrying
|
|
57
|
+
namespace URIs resolved against the scope it was refused from.
|
|
58
|
+
|
|
59
|
+
## [0.8.0] - 2026-07-12
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
* `Document#import_node` (DOM `importNode` / `adoptNode`) now imports an HTML
|
|
64
|
+
element whose name is a valid DOM name but not a well-formed XML QName (e.g.
|
|
65
|
+
`":good:times:"`, `"x<"`, `"0:a"`) instead of raising. Such an element is not
|
|
66
|
+
XML-serializable (`#to_xml` raises). An element carrying a similarly lenient
|
|
67
|
+
attribute name is still rejected.
|
|
68
|
+
|
|
3
69
|
## [0.7.0] - 2026-07-11
|
|
4
70
|
|
|
5
71
|
### Added
|
data/README.md
CHANGED
|
@@ -110,7 +110,9 @@ doc.css("feed > entry").map { |e| e.at_css("title").text } # => ["Hello", "Worl
|
|
|
110
110
|
|
|
111
111
|
# Serialize back to XML
|
|
112
112
|
doc.to_xml # => "<?xml version=\"1.0\"?>\n<feed ...>...</feed>\n"
|
|
113
|
-
|
|
113
|
+
# A node below the root serializes self-contained: no XML declaration, but the
|
|
114
|
+
# namespace declarations its subtree needs, so the output re-parses the same.
|
|
115
|
+
doc.at_xpath("//a:entry", ns).to_xml # => "<entry xmlns=\"http://www.w3.org/2005/Atom\"><title>Hello</title></entry>"
|
|
114
116
|
doc.to_xml(pretty: true) # indented, element-only content
|
|
115
117
|
|
|
116
118
|
# DOCTYPE is recognized but the DTD is not processed (no entities, no I/O):
|
|
@@ -138,8 +140,11 @@ doc.root.to_xml # => "<feed xmlns:dc=\"urn:dc\"><post dc:k=\"v\">Bye</
|
|
|
138
140
|
```
|
|
139
141
|
|
|
140
142
|
XML subtrees can be built with `Document#create_element` and related node factory methods,
|
|
141
|
-
then inserted with `#add_child`, `#before`, `#after`, or `#replace
|
|
142
|
-
|
|
143
|
+
then inserted with `#add_child`, `#before`, `#after`, or `#replace`. A factory-built
|
|
144
|
+
node takes its namespace from the context it is first inserted into; one that already
|
|
145
|
+
has a namespace keeps it. A node from another document is **adopted** — brought over
|
|
146
|
+
and removed from the document it came from — and the method returns the node now in
|
|
147
|
+
the tree, which is a different object than the one passed in.
|
|
143
148
|
|
|
144
149
|
`Document#import_node(node, deep = false)` brings a node into a document as a
|
|
145
150
|
detached copy, and works **across representations**: importing a `Makiri::HTML`
|
|
@@ -240,6 +245,34 @@ Detailed, test-backed notes live in `spec/conformance/README.md`.
|
|
|
240
245
|
(QNames), never PI targets. Nokogiri/libxml2 rejects it (`colons are forbidden
|
|
241
246
|
from PI names`); Makiri follows the normative text. Only the reserved `xml`
|
|
242
247
|
(any case) target is rejected.
|
|
248
|
+
* A node's **namespace URI is its identity**, not something re-derived from the
|
|
249
|
+
declarations around it - the WHATWG DOM model, measured against Chrome 152
|
|
250
|
+
(`DOMParser` + `XMLSerializer`).
|
|
251
|
+
* Moving a node under an element that binds its prefix to a different URI does
|
|
252
|
+
**not** change `namespace_uri`; the serializer emits the declaration the
|
|
253
|
+
output needs (`<p:x xmlns:p="urn:a"/>`), and nothing when the destination
|
|
254
|
+
already agrees. libxml2 keeps the URI on an in-document move but does *not*
|
|
255
|
+
emit the declaration, so Nokogiri's tree and its own output disagree there.
|
|
256
|
+
* `#to_xml` on a node below the root is **self-contained**: it declares the
|
|
257
|
+
prefixes its subtree uses, so the output re-parses to the same namespaces
|
|
258
|
+
standing alone. Nokogiri omits them, and its subtree output does not
|
|
259
|
+
round-trip.
|
|
260
|
+
* Where one prefix would have to mean two things at once, the serializer
|
|
261
|
+
invents one (`ns1`, `ns2`, ...) rather than shadow the other, as browsers do.
|
|
262
|
+
* An element in **no** namespace stays that way under a default namespace,
|
|
263
|
+
serialized as `xmlns=""`.
|
|
264
|
+
* Nodes from the factories (`create_element` and friends) still take their
|
|
265
|
+
namespace from the context they are first inserted into, so a subtree can be
|
|
266
|
+
built detached and attached afterwards. Only later moves carry.
|
|
267
|
+
* Inserting a node **from another document adopts it** (`add_child` / `before` /
|
|
268
|
+
`after` / `replace`): it is brought over and taken out of the document it came
|
|
269
|
+
from, as `appendChild` does in the DOM and in both Chrome and Nokogiri.
|
|
270
|
+
* Each arena owns its own nodes, so the node cannot be relinked across them: it
|
|
271
|
+
is copied here and removed there. The one visible difference from Nokogiri
|
|
272
|
+
and browsers is that the node handed back is a **different object** than the
|
|
273
|
+
one passed in - use the return value afterwards, not the argument.
|
|
274
|
+
* `Document#import_node` is the copy: it leaves the source alone, like DOM
|
|
275
|
+
`importNode`.
|
|
243
276
|
* Otherwise the parsed tree is byte-identical to `Nokogiri::XML`'s (verified by
|
|
244
277
|
the property-based differential), including namespaces, prolog/epilog comments
|
|
245
278
|
and PIs, and adjacent-CDATA coalescing.
|
|
@@ -267,6 +300,21 @@ Detailed, test-backed notes live in `spec/conformance/README.md`.
|
|
|
267
300
|
* Type selectors are ASCII case-insensitive (CSS-correct for HTML; `LI` matches `<li>`)
|
|
268
301
|
* `Nokogiri::HTML5` is case-sensitive there.
|
|
269
302
|
|
|
303
|
+
### Serialization
|
|
304
|
+
|
|
305
|
+
* **Comment data is written literally**, as the WHATWG serialization algorithm
|
|
306
|
+
says and as browsers do: `comment.content = "a-->b"` serializes to
|
|
307
|
+
`<!--a-->b-->`, which re-parses as the comment `"a"` followed by text.
|
|
308
|
+
* `Nokogiri::HTML5` escapes it to `<!--a-->b-->` instead. That does not
|
|
309
|
+
round-trip either - comments do not decode entities, so the data comes back
|
|
310
|
+
as `"a-->b"`. Neither library round-trips this; Makiri matches Chrome.
|
|
311
|
+
* The same applies to the children of `style` / `script` / `xmp` / `iframe` /
|
|
312
|
+
`noembed` / `noframes` / `plaintext`, which the algorithm also writes
|
|
313
|
+
literally. `noscript` is **escaped**, because Makiri parses it with scripting
|
|
314
|
+
disabled (its children are elements, not raw text) and escaping is what makes
|
|
315
|
+
that round-trip; `Nokogiri::HTML5` writes it literally and contradicts its own
|
|
316
|
+
parser there.
|
|
317
|
+
|
|
270
318
|
### Text input (mutation APIs)
|
|
271
319
|
|
|
272
320
|
* Programmatic string arguments must be **valid UTF-8** (invalid bytes raise
|
data/Rakefile
CHANGED
|
@@ -88,7 +88,12 @@ begin
|
|
|
88
88
|
"--error-limit=no",
|
|
89
89
|
"--trace-children=yes", # spec processes may fork
|
|
90
90
|
"--undef-value-errors=yes", # the point of this job (ruby_memcheck defaults to =no)
|
|
91
|
-
|
|
91
|
+
# Origin tracking (where an uninitialised value came from) roughly DOUBLES
|
|
92
|
+
# memcheck's run time. It is a diagnostic aid, not a detection capability -
|
|
93
|
+
# memcheck still reports the uninitialised USE without it - so the frequent
|
|
94
|
+
# post-merge push gate turns it off (VALGRIND_TRACK_ORIGINS=no) to run in
|
|
95
|
+
# ~half the time, while the nightly / manual runs keep it on for the backtrace.
|
|
96
|
+
"--track-origins=#{ENV.fetch('VALGRIND_TRACK_ORIGINS', 'yes')}",
|
|
92
97
|
"--leak-check=no", # leaks are `rake leaks`' job, not this one
|
|
93
98
|
],
|
|
94
99
|
)
|
|
@@ -96,7 +101,34 @@ begin
|
|
|
96
101
|
namespace :spec do
|
|
97
102
|
desc "Run the spec suite under Valgrind memcheck (ruby_memcheck; needs the " \
|
|
98
103
|
":valgrind bundler group and the valgrind binary)"
|
|
99
|
-
RubyMemcheck::RSpec::RakeTask.new(valgrind: :compile)
|
|
104
|
+
RubyMemcheck::RSpec::RakeTask.new(valgrind: :compile) do |t|
|
|
105
|
+
# Let spec_helper skip :slow examples (fail-closed limit tests whose sheer
|
|
106
|
+
# volume dominates memcheck without adding memory-safety coverage).
|
|
107
|
+
ENV["VALGRIND"] = "1"
|
|
108
|
+
|
|
109
|
+
# Optional sharding for CI wall-clock: with VALGRIND_SHARDS=N (>1) the spec
|
|
110
|
+
# files are partitioned into N groups and this run does only group
|
|
111
|
+
# VALGRIND_SHARD_INDEX, so N parallel matrix jobs cover the whole suite in
|
|
112
|
+
# ~1/N the time WITHOUT dropping any coverage. Files are packed
|
|
113
|
+
# largest-first onto the currently-lightest shard (greedy LPT by file size,
|
|
114
|
+
# a proxy for runtime), so the slowest shard - which sets wall-clock - is
|
|
115
|
+
# balanced rather than left to name order. Deterministic, so every shard
|
|
116
|
+
# computes the same partition and takes its own index. Default N=1 = the
|
|
117
|
+
# whole suite (local `rake spec:valgrind`).
|
|
118
|
+
shards = Integer(ENV.fetch("VALGRIND_SHARDS", "1"))
|
|
119
|
+
if shards > 1
|
|
120
|
+
index = Integer(ENV.fetch("VALGRIND_SHARD_INDEX", "0"))
|
|
121
|
+
buckets = Array.new(shards) { { load: 0, files: [] } }
|
|
122
|
+
# sort by (size desc, name) so the packing is fully deterministic across
|
|
123
|
+
# shards regardless of Array#sort stability.
|
|
124
|
+
Dir["spec/**/*_spec.rb"].sort_by { |f| [-File.size(f), f] }.each do |f|
|
|
125
|
+
b = buckets.min_by { |x| x[:load] }
|
|
126
|
+
b[:files] << f
|
|
127
|
+
b[:load] += File.size(f)
|
|
128
|
+
end
|
|
129
|
+
t.pattern = buckets.fetch(index)[:files]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
100
132
|
end
|
|
101
133
|
rescue LoadError
|
|
102
134
|
# ruby_memcheck not installed (optional :valgrind group absent) - skip the task.
|
|
@@ -194,6 +226,63 @@ task :sanitize do
|
|
|
194
226
|
sh(env, "#{FileUtils::RUBY} -S rspec")
|
|
195
227
|
end
|
|
196
228
|
|
|
229
|
+
# The randomised property checks in spec/invariants/. They are not *_spec.rb, so
|
|
230
|
+
# `rake spec` skips them; they run here and nightly in CI. Each takes
|
|
231
|
+
# [documents] [seed] [html|xml] and is deterministic, so a finding replays.
|
|
232
|
+
INVARIANT_CHECKS = [
|
|
233
|
+
["check_ns_reresolve.rb", %w[]],
|
|
234
|
+
["check_import_clone.rb", %w[]],
|
|
235
|
+
["check_tree_invariants.rb", %w[html xml]],
|
|
236
|
+
["check_index_staleness.rb", %w[html xml]],
|
|
237
|
+
["check_serialize_fixpoint.rb", %w[html xml]],
|
|
238
|
+
["check_text_input.rb", nil], # takes no count
|
|
239
|
+
].freeze
|
|
240
|
+
|
|
241
|
+
# [[script, argv], ...] for a given document count.
|
|
242
|
+
def invariant_runs(count)
|
|
243
|
+
INVARIANT_CHECKS.flat_map do |script, backends|
|
|
244
|
+
path = "spec/invariants/#{script}"
|
|
245
|
+
next [[path, []]] if backends.nil?
|
|
246
|
+
next [[path, [count.to_s]]] if backends.empty?
|
|
247
|
+
|
|
248
|
+
backends.map { |b| [path, [count.to_s, "20260911", b]] }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
desc "Run the invariant checks (override the document count via INVARIANT_COUNT)"
|
|
253
|
+
task invariants: :compile do
|
|
254
|
+
count = (ENV["INVARIANT_COUNT"] || 2000).to_i
|
|
255
|
+
invariant_runs(count).each do |script, argv|
|
|
256
|
+
sh "#{FileUtils::RUBY} -Ilib #{script} #{argv.join(' ')}"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
desc "Run the invariant checks under AddressSanitizer + UBSan (the text index " \
|
|
261
|
+
"holds borrowed slices, so staleness there is a memory bug too)"
|
|
262
|
+
task "invariants:sanitize" do
|
|
263
|
+
sanitize = ENV["MAKIRI_SANITIZE"] || "address,undefined"
|
|
264
|
+
sh({ "MAKIRI_SANITIZE" => sanitize }, "#{FileUtils::RUBY} -S rake clean compile")
|
|
265
|
+
|
|
266
|
+
env = {
|
|
267
|
+
"ASAN_OPTIONS" => "detect_leaks=0:detect_container_overflow=0:" \
|
|
268
|
+
"detect_odr_violation=0:abort_on_error=1:halt_on_error=1",
|
|
269
|
+
"UBSAN_OPTIONS" => "print_stacktrace=1:halt_on_error=1",
|
|
270
|
+
}
|
|
271
|
+
if sanitize.include?("address")
|
|
272
|
+
runtime = asan_runtime_path or
|
|
273
|
+
abort "invariants:sanitize: could not locate the ASan runtime for #{RbConfig::CONFIG['CC']}"
|
|
274
|
+
preload = RbConfig::CONFIG["target_os"] =~ /darwin/ ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD"
|
|
275
|
+
env[preload] = runtime
|
|
276
|
+
puts "invariants:sanitize: preloading #{runtime} via #{preload}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Instrumented builds are slow; a smaller sweep still exercises every path.
|
|
280
|
+
count = (ENV["INVARIANT_COUNT"] || 500).to_i
|
|
281
|
+
invariant_runs(count).each do |script, argv|
|
|
282
|
+
sh(env, "#{FileUtils::RUBY} -Ilib #{script} #{argv.join(' ')}")
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
197
286
|
desc "Measure C coverage of OUR sources (clang source-based) over the spec suite. " \
|
|
198
287
|
"Prints an llvm-cov region+branch report (excludes vendored Lexbor) and writes " \
|
|
199
288
|
"a line-level detail file to tmp/coverage/show.txt."
|
|
@@ -185,15 +185,37 @@ h2x_make(mkr_xml_doc_t *xdoc, lxb_dom_node_t *s, const char *pdef, uint32_t pdef
|
|
|
185
185
|
size_t nl;
|
|
186
186
|
const lxb_char_t *nm = lxb_dom_element_qualified_name(lxb_dom_interface_element(s), &nl);
|
|
187
187
|
if (!MKR_FITS_U32(nl)) return MKR_XML_MUT_OOM;
|
|
188
|
+
|
|
189
|
+
/* The element's namespace URI (borrowed from the source doc's ns table). */
|
|
190
|
+
uint32_t eul;
|
|
191
|
+
const char *euri = mkr_html_ns_uri(s, &eul);
|
|
192
|
+
|
|
193
|
+
/* Strict first, so a valid QName stays XML-serializable. importNode/adoptNode
|
|
194
|
+
* never re-validate an existing node's name (DOM requires the source already
|
|
195
|
+
* be well-formed for its representation), so an HTML name that is a valid DOM
|
|
196
|
+
* element name but NOT a well-formed XML QName (":good:times:", "x<", "0:a")
|
|
197
|
+
* is taken VERBATIM as an unprefixed DOM-loose name (local == whole) rather
|
|
198
|
+
* than rejected - the same non-serializable escape hatch as
|
|
199
|
+
* create_loose_dom_element. HTML elements are always unprefixed. The namespace
|
|
200
|
+
* is passed DIRECTLY because ns resolution at link time skips loose names
|
|
201
|
+
* (resolve_node_ns), so a synthesized xmlns declaration would not reach it. */
|
|
188
202
|
mkr_xml_node_t *el = NULL;
|
|
189
203
|
mkr_xml_mut_status_t st = mkr_xml_new_element(xdoc, (const char *)nm, (uint32_t)nl, &el);
|
|
204
|
+
if (st == MKR_XML_MUT_BAD_NAME && nl > 0) {
|
|
205
|
+
mkr_xml_qname_t qn = {
|
|
206
|
+
(const char *)nm, (uint32_t)nl, /* qname */
|
|
207
|
+
(const char *)nm, 0, /* prefix (none) */
|
|
208
|
+
(const char *)nm, (uint32_t)nl, /* local == qname */
|
|
209
|
+
};
|
|
210
|
+
st = mkr_xml_new_loose_dom_element(xdoc, &qn, euri, eul, &el);
|
|
211
|
+
}
|
|
190
212
|
if (st != MKR_XML_MUT_OK) return st;
|
|
191
213
|
|
|
192
|
-
/* Declare the
|
|
193
|
-
*
|
|
194
|
-
* An element with no namespace under an inherited default undeclares
|
|
195
|
-
|
|
196
|
-
|
|
214
|
+
/* Declare the default namespace iff it differs from the inherited one, so
|
|
215
|
+
* this (unprefixed, like all HTML elements) element and its children resolve
|
|
216
|
+
* to it. An element with no namespace under an inherited default undeclares
|
|
217
|
+
* (xmlns=""). For a loose element this only feeds child inheritance - its own
|
|
218
|
+
* ns_uri was already set directly above. */
|
|
197
219
|
if (!mkr_uri_eq(euri, eul, pdef, pdef_len)) {
|
|
198
220
|
st = h2x_declare_ns(xdoc, el, NULL, 0, euri, eul);
|
|
199
221
|
if (st != MKR_XML_MUT_OK) return st;
|
data/ext/makiri/glue/glue.h
CHANGED
|
@@ -124,6 +124,7 @@ lxb_dom_node_t *mkr_run_fragment_parser(VALUE html, mkr_fragment_parse_fn parse,
|
|
|
124
124
|
* Implemented in ruby_doc.c (next to the import machinery), bound in
|
|
125
125
|
* mkr_init_node. */
|
|
126
126
|
VALUE mkr_node_clone_node(int argc, VALUE *argv, VALUE self);
|
|
127
|
+
lxb_dom_node_t *mkr_html_import_deep(lxb_dom_document_t *doc, lxb_dom_node_t *src);
|
|
127
128
|
|
|
128
129
|
/* NodeSet bridge (glue/ruby_node_set.c). mkr_raw_node_t (above): callers cast
|
|
129
130
|
* their typed node to it when pushing (forgetting the type is the safe, store
|
data/ext/makiri/glue/ruby_doc.c
CHANGED
|
@@ -339,6 +339,20 @@ mkr_run_fragment_parser(VALUE html, mkr_fragment_parse_fn parse, void *ctx)
|
|
|
339
339
|
return root;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/* Deep-import +src+ into +doc+, <template> contents included - the copy step of
|
|
343
|
+
* adopting a node from another document (ruby_html_mutate.c). Raises rather than
|
|
344
|
+
* returning a partial node. */
|
|
345
|
+
lxb_dom_node_t *
|
|
346
|
+
mkr_html_import_deep(lxb_dom_document_t *doc, lxb_dom_node_t *src)
|
|
347
|
+
{
|
|
348
|
+
lxb_dom_node_t *imp = lxb_dom_document_import_node(doc, src, true);
|
|
349
|
+
if (imp == NULL) {
|
|
350
|
+
rb_raise(mkr_eError, "failed to import node");
|
|
351
|
+
}
|
|
352
|
+
mkr_fixup_template_content(doc, src, imp);
|
|
353
|
+
return imp;
|
|
354
|
+
}
|
|
355
|
+
|
|
342
356
|
/* Node#clone_node(deep = false): a shallow (or deep, with deep truthy) copy of
|
|
343
357
|
* this node, owned by the same document and detached from any parent - the DOM
|
|
344
358
|
* cloneNode, whose `deep` defaults to false (a missing/nil/false argument =>
|
|
@@ -26,11 +26,13 @@ lxb_dom_attr_qualified_name_append(lexbor_hash_t *hash, const lxb_char_t *name,
|
|
|
26
26
|
* DOM mutation (v0.2). Thin wrappers over Lexbor's insert/remove/create
|
|
27
27
|
* functions, with the safety checks Lexbor itself omits:
|
|
28
28
|
*
|
|
29
|
-
* - same-document: a node can only be inserted into its own document
|
|
30
|
-
* (cross-document moves would splice foreign-arena pointers);
|
|
31
29
|
* - no cycles: a node cannot become a descendant of itself;
|
|
32
30
|
* - attribute nodes are not tree children.
|
|
33
31
|
*
|
|
32
|
+
* A node from another document is ADOPTED, as the DOM says appendChild does: it
|
|
33
|
+
* cannot be relinked across arenas, so it is copied here and released there
|
|
34
|
+
* (mkr_adopt_copy / mkr_adopt_release), and the verb hands back the copy.
|
|
35
|
+
*
|
|
34
36
|
* We never destroy detached nodes: the document arena owns all node memory and
|
|
35
37
|
* frees it wholesale, and live Ruby wrappers may still point at a removed node.
|
|
36
38
|
* `remove`/`unlink` therefore only detach.
|
|
@@ -57,26 +59,81 @@ mkr_arg_node(VALUE v)
|
|
|
57
59
|
return mkr_html_node_unwrap(v);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/*
|
|
61
|
-
*
|
|
62
|
+
/* Copy +node+ into +doc+, for a node that came from another document. Lexbor's
|
|
63
|
+
* arenas own their own nodes, so it cannot be relinked across them; the copy is
|
|
64
|
+
* this half of the DOM's adopt, and mkr_adopt_release is the other. */
|
|
65
|
+
static lxb_dom_node_t *
|
|
66
|
+
mkr_adopt_copy(lxb_dom_document_t *doc, lxb_dom_node_t *node)
|
|
67
|
+
{
|
|
68
|
+
return mkr_html_import_deep(doc, node);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* The other half: take +node+ out of the document it came from, so the whole
|
|
72
|
+
* thing reads as the move the DOM says appendChild performs.
|
|
73
|
+
*
|
|
74
|
+
* Called only AFTER the insert has gone through, so a refused one leaves the
|
|
75
|
+
* source document alone - the same ordering the XML backend uses. */
|
|
62
76
|
static void
|
|
63
|
-
|
|
77
|
+
mkr_adopt_release(lxb_dom_node_t *node)
|
|
78
|
+
{
|
|
79
|
+
if (node->type == LXB_DOM_NODE_TYPE_DOCUMENT_FRAGMENT) {
|
|
80
|
+
/* A fragment contributes its children; the DOM leaves a spliced one
|
|
81
|
+
* empty, so empty the source rather than detaching it. */
|
|
82
|
+
lxb_dom_node_t *c;
|
|
83
|
+
while ((c = node->first_child) != NULL) lxb_dom_node_remove(c);
|
|
84
|
+
} else if (node->parent != NULL) {
|
|
85
|
+
lxb_dom_node_remove(node);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Validate that `rb_incoming` may be placed relative to `ref`, detach it from
|
|
90
|
+
* any current parent (move semantics), and return the node to actually insert.
|
|
91
|
+
*
|
|
92
|
+
* For a node from another document that is its copy (see mkr_adopt_copy), so
|
|
93
|
+
* the returned node is not always the one passed in and the caller must insert
|
|
94
|
+
* - and hand back - what this returns. *+adopt_from+ receives the argument in
|
|
95
|
+
* that case, for mkr_inserted_result to release once the insert has gone
|
|
96
|
+
* through; Qnil otherwise. Holding the Ruby VALUE, rather than the raw node,
|
|
97
|
+
* also keeps the source document reachable until then - the same protocol the
|
|
98
|
+
* XML backend uses (mkr_xml_incoming_node / mkr_xml_adopt_finish).
|
|
99
|
+
*
|
|
100
|
+
* Raises on the unsafe cases. */
|
|
101
|
+
static lxb_dom_node_t *
|
|
102
|
+
mkr_prepare_insert(lxb_dom_node_t *ref, VALUE rb_incoming, VALUE *adopt_from)
|
|
64
103
|
{
|
|
104
|
+
lxb_dom_node_t *incoming = mkr_arg_node(rb_incoming);
|
|
105
|
+
|
|
106
|
+
*adopt_from = Qnil;
|
|
65
107
|
if (incoming->type == LXB_DOM_NODE_TYPE_ATTRIBUTE) {
|
|
66
108
|
rb_raise(mkr_eError, "an attribute node cannot be inserted into the tree");
|
|
67
109
|
}
|
|
68
|
-
if (ref->owner_document != incoming->owner_document) {
|
|
69
|
-
rb_raise(mkr_eError, "cannot move a node between documents");
|
|
70
|
-
}
|
|
71
110
|
/* incoming must not be an inclusive ancestor of ref. */
|
|
72
111
|
for (lxb_dom_node_t *p = ref; p != NULL; p = p->parent) {
|
|
73
112
|
if (p == incoming) {
|
|
74
113
|
rb_raise(mkr_eError, "cannot insert a node into its own subtree");
|
|
75
114
|
}
|
|
76
115
|
}
|
|
116
|
+
if (ref->owner_document != incoming->owner_document) {
|
|
117
|
+
*adopt_from = rb_incoming;
|
|
118
|
+
return mkr_adopt_copy(ref->owner_document, incoming);
|
|
119
|
+
}
|
|
77
120
|
if (incoming->parent != NULL) {
|
|
78
121
|
lxb_dom_node_remove(incoming);
|
|
79
122
|
}
|
|
123
|
+
return incoming;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* The value an insertion verb hands back: its argument, or - when the node was
|
|
127
|
+
* adopted - the node now in the tree, which is a different object. Finishing the
|
|
128
|
+
* adoption here keeps the release after the insert, where it belongs. */
|
|
129
|
+
static VALUE
|
|
130
|
+
mkr_inserted_result(VALUE self, VALUE rb_arg, lxb_dom_node_t *inserted,
|
|
131
|
+
VALUE adopt_from)
|
|
132
|
+
{
|
|
133
|
+
if (NIL_P(adopt_from)) return rb_arg;
|
|
134
|
+
|
|
135
|
+
mkr_adopt_release(mkr_arg_node(adopt_from));
|
|
136
|
+
return mkr_wrap_html_node(inserted, mkr_node_document(self));
|
|
80
137
|
}
|
|
81
138
|
|
|
82
139
|
/* WHATWG doctype ordering at the document node (https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity),
|
|
@@ -194,12 +251,12 @@ static VALUE
|
|
|
194
251
|
mkr_node_add_child(VALUE self, VALUE rb_child)
|
|
195
252
|
{
|
|
196
253
|
lxb_dom_node_t *parent = mkr_node_unwrap_mutable(self);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
mkr_prepare_insert(parent,
|
|
200
|
-
mkr_splice_or_insert(parent,
|
|
254
|
+
mkr_guard_doc_child_order(parent, NULL, NULL, mkr_arg_node(rb_child)); /* append */
|
|
255
|
+
VALUE adopt_from;
|
|
256
|
+
lxb_dom_node_t *ins = mkr_prepare_insert(parent, rb_child, &adopt_from);
|
|
257
|
+
mkr_splice_or_insert(parent, ins, lxb_dom_node_insert_child, 0);
|
|
201
258
|
mkr_invalidate_index(self);
|
|
202
|
-
return rb_child;
|
|
259
|
+
return mkr_inserted_result(self, rb_child, ins, adopt_from);
|
|
203
260
|
}
|
|
204
261
|
|
|
205
262
|
/* node << child -> node (chainable). */
|
|
@@ -214,30 +271,30 @@ static VALUE
|
|
|
214
271
|
mkr_node_add_previous_sibling(VALUE self, VALUE rb_node)
|
|
215
272
|
{
|
|
216
273
|
lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
|
|
217
|
-
lxb_dom_node_t *node = mkr_arg_node(rb_node);
|
|
218
274
|
if (ref->parent == NULL) {
|
|
219
275
|
rb_raise(mkr_eError, "cannot add a sibling to a node with no parent");
|
|
220
276
|
}
|
|
221
|
-
mkr_guard_doc_child_order(ref->parent, ref, NULL,
|
|
222
|
-
|
|
223
|
-
|
|
277
|
+
mkr_guard_doc_child_order(ref->parent, ref, NULL, mkr_arg_node(rb_node));
|
|
278
|
+
VALUE adopt_from;
|
|
279
|
+
lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_node, &adopt_from);
|
|
280
|
+
mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_before, 0);
|
|
224
281
|
mkr_invalidate_index(self);
|
|
225
|
-
return rb_node;
|
|
282
|
+
return mkr_inserted_result(self, rb_node, ins, adopt_from);
|
|
226
283
|
}
|
|
227
284
|
|
|
228
285
|
static VALUE
|
|
229
286
|
mkr_node_add_next_sibling(VALUE self, VALUE rb_node)
|
|
230
287
|
{
|
|
231
288
|
lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
|
|
232
|
-
lxb_dom_node_t *node = mkr_arg_node(rb_node);
|
|
233
289
|
if (ref->parent == NULL) {
|
|
234
290
|
rb_raise(mkr_eError, "cannot add a sibling to a node with no parent");
|
|
235
291
|
}
|
|
236
|
-
mkr_guard_doc_child_order(ref->parent, ref->next, NULL,
|
|
237
|
-
|
|
238
|
-
|
|
292
|
+
mkr_guard_doc_child_order(ref->parent, ref->next, NULL, mkr_arg_node(rb_node));
|
|
293
|
+
VALUE adopt_from;
|
|
294
|
+
lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_node, &adopt_from);
|
|
295
|
+
mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_after, 1);
|
|
239
296
|
mkr_invalidate_index(self);
|
|
240
|
-
return rb_node;
|
|
297
|
+
return mkr_inserted_result(self, rb_node, ins, adopt_from);
|
|
241
298
|
}
|
|
242
299
|
|
|
243
300
|
/* node.remove / node.unlink -> node. Detaches from the tree (still usable). */
|
|
@@ -260,16 +317,16 @@ static VALUE
|
|
|
260
317
|
mkr_node_replace(VALUE self, VALUE rb_other)
|
|
261
318
|
{
|
|
262
319
|
lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
|
|
263
|
-
lxb_dom_node_t *other = mkr_arg_node(rb_other);
|
|
264
320
|
if (ref->parent == NULL) {
|
|
265
321
|
rb_raise(mkr_eError, "cannot replace a node with no parent");
|
|
266
322
|
}
|
|
267
|
-
mkr_guard_doc_child_order(ref->parent, ref, ref,
|
|
268
|
-
|
|
269
|
-
|
|
323
|
+
mkr_guard_doc_child_order(ref->parent, ref, ref, mkr_arg_node(rb_other));
|
|
324
|
+
VALUE adopt_from;
|
|
325
|
+
lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_other, &adopt_from);
|
|
326
|
+
mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_before, 0);
|
|
270
327
|
lxb_dom_node_remove(ref);
|
|
271
328
|
mkr_invalidate_index(self);
|
|
272
|
-
return rb_other;
|
|
329
|
+
return mkr_inserted_result(self, rb_other, ins, adopt_from);
|
|
273
330
|
}
|
|
274
331
|
|
|
275
332
|
/* ------------------------------------------------------------------ */
|