@variance-authority/core 0.1.0 → 0.2.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.
- package/CHANGELOG.md +192 -0
- package/README.md +51 -9
- package/dist/attribute/closure.d.ts +105 -0
- package/dist/attribute/closure.js +180 -0
- package/dist/attribute/closure.js.map +1 -0
- package/dist/attribute/composition.d.ts +5 -3
- package/dist/attribute/composition.js.map +1 -1
- package/dist/attribute/divergence.js +7 -1
- package/dist/attribute/divergence.js.map +1 -1
- package/dist/attribute/index.d.ts +5 -1
- package/dist/attribute/index.js +3 -1
- package/dist/attribute/index.js.map +1 -1
- package/dist/attribute/lexicon.d.ts +88 -0
- package/dist/attribute/lexicon.js +150 -0
- package/dist/attribute/lexicon.js.map +1 -0
- package/dist/attribute/source.d.ts +19 -2
- package/dist/attribute/source.js +12 -0
- package/dist/attribute/source.js.map +1 -1
- package/dist/compare/derive.d.ts +17 -0
- package/dist/compare/derive.js.map +1 -1
- package/dist/compare/diff/index.d.ts +25 -1
- package/dist/compare/diff/index.js +8 -5
- package/dist/compare/diff/index.js.map +1 -1
- package/dist/compare/explain.d.ts +1 -1
- package/dist/compare/explain.js +15 -8
- package/dist/compare/explain.js.map +1 -1
- package/dist/compare/index.d.ts +2 -2
- package/dist/compare/index.js +1 -1
- package/dist/compare/index.js.map +1 -1
- package/dist/compare/observability.d.ts +25 -1
- package/dist/compare/observability.js +54 -0
- package/dist/compare/observability.js.map +1 -1
- package/dist/compare/parting.d.ts +24 -2
- package/dist/compare/parting.js +14 -9
- package/dist/compare/parting.js.map +1 -1
- package/dist/compare/slice.d.ts +15 -2
- package/dist/compare/slice.js +6 -2
- package/dist/compare/slice.js.map +1 -1
- package/dist/format/capture.d.ts +15 -0
- package/dist/format/capture.js +30 -1
- package/dist/format/capture.js.map +1 -1
- package/dist/format/document.d.ts +65 -5
- package/dist/format/document.js +34 -0
- package/dist/format/document.js.map +1 -1
- package/dist/format/hash.d.ts +26 -0
- package/dist/format/hash.js +41 -0
- package/dist/format/hash.js.map +1 -1
- package/dist/format/index.d.ts +4 -3
- package/dist/format/index.js +3 -2
- package/dist/format/index.js.map +1 -1
- package/dist/format/sha256.d.ts +10 -2
- package/dist/format/sha256.js +42 -9
- package/dist/format/sha256.js.map +1 -1
- package/dist/format/stabilize.d.ts +15 -0
- package/dist/format/stabilize.js +22 -1
- package/dist/format/stabilize.js.map +1 -1
- package/dist/index.d.ts +3 -11
- package/dist/index.js +3 -11
- package/dist/index.js.map +1 -1
- package/dist/relate/graph.d.ts +26 -6
- package/dist/relate/graph.js +14 -7
- package/dist/relate/graph.js.map +1 -1
- package/dist/relate/index.d.ts +2 -2
- package/dist/relate/index.js +1 -1
- package/dist/relate/index.js.map +1 -1
- package/dist/relate/merkle.d.ts +8 -5
- package/dist/relate/merkle.js +8 -6
- package/dist/relate/merkle.js.map +1 -1
- package/dist/relate/reach.d.ts +19 -5
- package/dist/relate/reach.js +10 -8
- package/dist/relate/reach.js.map +1 -1
- package/dist/relate/records.d.ts +29 -4
- package/dist/relate/records.js +60 -7
- package/dist/relate/records.js.map +1 -1
- package/dist/segment/index.d.ts +123 -0
- package/dist/segment/index.js +265 -0
- package/dist/segment/index.js.map +1 -0
- package/dist/share/index.d.ts +148 -0
- package/dist/share/index.js +190 -0
- package/dist/share/index.js.map +1 -0
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,197 @@
|
|
|
1
1
|
# @variance-authority/core
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e546e21: A collector's complaints travel into the observation
|
|
8
|
+
|
|
9
|
+
A finding produced at collection reached the CLI's record and nobody else. A
|
|
10
|
+
suite driving this from its own runner asks the observation what is wrong with a
|
|
11
|
+
subject, and the one diagnostic that explained the moved pixels — the host, not
|
|
12
|
+
the stylesheet, chose the typeface — was produced, carried, and dropped one call
|
|
13
|
+
short of the person reading the failure.
|
|
14
|
+
|
|
15
|
+
`mergeDiagnostics` dedupes on every field, so the CLI, which now meets the same
|
|
16
|
+
list twice, says a shared complaint once.
|
|
17
|
+
- f4b328b: Mainline's evaluation can travel, so the next machine does not derive it again
|
|
18
|
+
|
|
19
|
+
A suite index is bytes addressed by the commit they were written at, which is
|
|
20
|
+
exactly the shape a cache already wants. What was missing was anywhere to put
|
|
21
|
+
them: every checkout that wanted to know what `main` looked like had to run
|
|
22
|
+
`main`, and in CI that is the whole suite, twice, on every pull request.
|
|
23
|
+
|
|
24
|
+
`@variance-authority/core/share` is the transport, and it has two backends
|
|
25
|
+
because there are two. A **directory** is what `actions/cache`, `aws s3 sync`, a
|
|
26
|
+
network mount and a laptop all are once the bytes are on disk; an **HTTP
|
|
27
|
+
endpoint** is what a bucket, a presigned URL and a tribunal deployment all are.
|
|
28
|
+
`createDirectoryShare` in `@variance-authority/store/share` is the filesystem
|
|
29
|
+
half, kept out of `core` because `core` takes no platform.
|
|
30
|
+
|
|
31
|
+
A share never fails a run. `get` answers `null` and `put` resolves, whatever the
|
|
32
|
+
network did — a share that is down, misconfigured or empty is indistinguishable
|
|
33
|
+
from a cold one, and the run derives its own index and continues. The cost is
|
|
34
|
+
deliberate: a typo in an endpoint is a suite that quietly got slower rather than
|
|
35
|
+
a build that went red, and the `behind` count `variance share` prints is the
|
|
36
|
+
signal that publishing has stopped.
|
|
37
|
+
|
|
38
|
+
The CLI gained a `share` section in its config, a `variance share` command that
|
|
39
|
+
says what the share holds for mainline or publishes what this run derived, and a
|
|
40
|
+
line on `variance run` naming where the index went. A lookup walks the lineage —
|
|
41
|
+
`merge-base` with the configured `mainline`, then first-parent — and asks this
|
|
42
|
+
machine's own cache for every commit before it asks the share, because the run
|
|
43
|
+
that just published is usually the one asking.
|
|
44
|
+
|
|
45
|
+
`docs/sharing.md` is the arrangement end to end, GitHub Actions first.
|
|
46
|
+
- e546e21: A subject is allowed to reference something that is not there
|
|
47
|
+
|
|
48
|
+
A fixture that points an `<img>` at a path nobody serves is testing the fallback,
|
|
49
|
+
and the broken state is the subject. Capture had no way to say so: the resolver
|
|
50
|
+
returned bytes or the capture was refused, so Material UI's CardMedia,
|
|
51
|
+
ImageListItem and Avatar suites — which all reference a deliberately absent
|
|
52
|
+
`/fake.png` — could not be captured at all.
|
|
53
|
+
|
|
54
|
+
`resolveResource` may answer `{ absent: true }`. The resource is recorded with
|
|
55
|
+
its status, and the renderer answers that status instead of counting the request
|
|
56
|
+
as one the document failed to carry.
|
|
57
|
+
- e546e21: A subject with no pixels is a baseline without an image, not a subject that got away
|
|
58
|
+
|
|
59
|
+
A wrapper whose only child went to a portal, or a conformance mount with no
|
|
60
|
+
children, occupies nothing. Refusing to photograph it is right; refusing the
|
|
61
|
+
subject was not. On Material UI's unit tier that was 1109 of 4371 subjects
|
|
62
|
+
reported as unobserved while the capture held their markup, their rules, their
|
|
63
|
+
component hashes and their accessibility tree — none of which was in doubt.
|
|
64
|
+
|
|
65
|
+
`Raster` makes the image optional: `bytes`, `width` and `height` are absent
|
|
66
|
+
together or present together, and `pictured` is the one place that narrows all
|
|
67
|
+
three. Absent means *this subject has no pixels*, which is a measurement — it
|
|
68
|
+
never means the image was lost. `occupiesPixels` asks the same question of a
|
|
69
|
+
record read without bytes, which is the only form a sidecar takes. `observe`
|
|
70
|
+
gets a second tier in `unpictured.ts`, where the comparison such a subject can
|
|
71
|
+
still take — document digest, component hashes, accessibility — is the whole
|
|
72
|
+
verdict. `promotionOf` promotes the sidecar alone when there is no `after`,
|
|
73
|
+
because the subject reached a verdict and the only missing half is the one a
|
|
74
|
+
camera would have produced.
|
|
75
|
+
|
|
76
|
+
The file-backed store carries the same nullable pair, and with it the split of a
|
|
77
|
+
baseline's two halves into two path prefixes. `identities` scans the record root,
|
|
78
|
+
because a subject with no pixels has no image directory to be found in and the
|
|
79
|
+
sibling scan would otherwise call another machine's baseline new.
|
|
80
|
+
|
|
81
|
+
Capture stops handing this to Playwright to fail on. Both screenshot paths used
|
|
82
|
+
to refuse a zero-area subject in terms of their own arguments — the clip path
|
|
83
|
+
with `Expected options.clip.height to be greater than 0`, the element path by
|
|
84
|
+
spending the full actionability timeout and then complaining about visibility —
|
|
85
|
+
so a reader had a component that rendered nothing and a sentence about a
|
|
86
|
+
rectangle. `captureSubject` decides it now, and the renderer names the subject.
|
|
87
|
+
|
|
88
|
+
**Operators:** this is schema **18**. `baselines` and `render_cache` drop
|
|
89
|
+
`NOT NULL` from `width` and `height`, shipped as `0016_pixel-less-baselines.sql`.
|
|
90
|
+
Apply it before pointing a CLI of this version at the deployment; `GET /version`
|
|
91
|
+
reports the schema a build expects.
|
|
92
|
+
- f4b328b: A run leaves behind what its suite looked like, in bytes another checkout can read
|
|
93
|
+
|
|
94
|
+
A run report answers about *this run*. Nothing answered about the suite from
|
|
95
|
+
somewhere else: which components exist, which subjects hold them, which of them
|
|
96
|
+
has an example of its own, and every name the run indexed. A branch asking any
|
|
97
|
+
of that against `main` had nothing to ask, because `main`'s answers were computed
|
|
98
|
+
on a machine that has since gone away — which is why *this component is not
|
|
99
|
+
covered anywhere* was a question with no reader, and why a second run could only
|
|
100
|
+
re-derive what the first already knew.
|
|
101
|
+
|
|
102
|
+
`@variance-authority/report/suite-index` is that artifact. `suiteIndexOf` takes
|
|
103
|
+
the baseline-bearing half of a report — the census, the subject denominator it is
|
|
104
|
+
counted against, and the lexicon — and `encodeSuiteIndex` writes it as one
|
|
105
|
+
segment, with `readSuiteIndex` and `writeSuiteIndex` beside the report's own file
|
|
106
|
+
functions. The other half of a composition does not travel: `movements` is what
|
|
107
|
+
moved since a comparison nobody else made, and `divergences` and `echoes` are
|
|
108
|
+
readings of one commit's snapshots. None of the three is a fact about the suite.
|
|
109
|
+
|
|
110
|
+
It carries the commit it was written at and nothing else about where it is, which
|
|
111
|
+
is the rule the selection index already settled — a commit answers *what changed
|
|
112
|
+
since this was written* exactly, and a timestamp says when a machine was rather
|
|
113
|
+
than where a tree was. An index that cannot name itself is the honest record of a
|
|
114
|
+
run that could not, and a reader holding one has nothing to diff.
|
|
115
|
+
|
|
116
|
+
It is not JSON. A lexicon is the same few thousand strings written once per
|
|
117
|
+
subject that holds them, and in JSON the repetition *is* the payload. Interned
|
|
118
|
+
once and referenced by number it stops being one, and equal facts encode to equal
|
|
119
|
+
bytes — a sorted dictionary, so a cache that keys on content actually hits.
|
|
120
|
+
|
|
121
|
+
`@variance-authority/core/segment` is the arithmetic underneath: named columns,
|
|
122
|
+
alignment, interned strings, and the validation a decode performs before it
|
|
123
|
+
believes a file. A column's width comes from the array's own type, so a mismatch
|
|
124
|
+
is a compile error rather than a decode against the wrong stride, and a malformed
|
|
125
|
+
reference rejects the whole file — a segment is a cache or a baseline, both of
|
|
126
|
+
which may be rebuilt, and half of either is worse than neither. The source index
|
|
127
|
+
now reads and writes through it and keeps its own bytes; what it still owns is the
|
|
128
|
+
part that is about source.
|
|
129
|
+
- e546e21: Two readings of two places are not a flake
|
|
130
|
+
|
|
131
|
+
`flake` is an accusation — *this reading is not repeatable* — and it was being
|
|
132
|
+
made about a comparison that never claimed repeatability. Lifting two instances
|
|
133
|
+
out of two subjects at one commit and parting them produces a case where every
|
|
134
|
+
input agrees, the tree holds, and the output moved; the old table had exactly one
|
|
135
|
+
row for that.
|
|
136
|
+
|
|
137
|
+
Where a component sits is decided by the boxes around it, and no component
|
|
138
|
+
receives its own position as a prop. Two instances that agreed on everything they
|
|
139
|
+
were handed and landed at different coordinates have contradicted nothing.
|
|
140
|
+
|
|
141
|
+
`partingOf` takes a `PartingPlace`: `same` for one subject read twice, across
|
|
142
|
+
revisions or across two moments of one scenario, and `elsewhere` for two
|
|
143
|
+
instances at one commit. It decides only between `flake` and the new `placed`
|
|
144
|
+
rung and slice, and it defaults to `same`, which is what every comparison across
|
|
145
|
+
revisions is. `divergencesOf` is the one caller that says `elsewhere`. The
|
|
146
|
+
sentence points outwards, at what put the component there, rather than at the
|
|
147
|
+
component.
|
|
148
|
+
|
|
149
|
+
### Patch Changes
|
|
150
|
+
|
|
151
|
+
- e546e21: Four ways a large unit tier lost subjects, none of which said so
|
|
152
|
+
|
|
153
|
+
**The styling was gone before the capture looked.** A capture taken from a setup
|
|
154
|
+
file runs in the outermost `afterEach` a run has; every hook registered inside a
|
|
155
|
+
`describe` has already finished, and `onTestFinished` runs later still. CSS-in-JS
|
|
156
|
+
teardown lives in exactly those inner hooks — emotion's test renderer removes each
|
|
157
|
+
`<style>` tag it inserted — so the capture read the page with the styling taken
|
|
158
|
+
back off it. The class names were all still in the markup, they matched nothing,
|
|
159
|
+
and every subject captured, compared and passed against a photograph of unstyled
|
|
160
|
+
DOM. Measured on Material UI's unit tier: 399 of 400 captures carried no CSS at
|
|
161
|
+
all, and 1201 subjects laid out to zero height because nothing was sizing them.
|
|
162
|
+
`retainStyles` records style elements as they are inserted and puts the removed
|
|
163
|
+
ones back, in insertion order, for the length of one capture. The next test still
|
|
164
|
+
gets a clean page.
|
|
165
|
+
|
|
166
|
+
**A subject id can be longer than a filename.** A suite that names subjects after
|
|
167
|
+
the test that produced them — a file path and a full test name — passes 255 bytes
|
|
168
|
+
on ordinary tests, and the point of that convention is that the id says where the
|
|
169
|
+
subject came from. `fileNameFor` in core is now the one rule: a readable prefix,
|
|
170
|
+
plus a digest of the whole id when the id does not fit, because truncation alone
|
|
171
|
+
merges two tests into one file. The baseline store, the capture archive, the
|
|
172
|
+
CLI's image directory, the Playwright evidence directory and the Eyes journal all
|
|
173
|
+
use it; before this, each was one long subject id away from a raw `ENAMETOOLONG`
|
|
174
|
+
that never mentions a subject. `@variance-authority/eyes` declares
|
|
175
|
+
`@variance-authority/core` directly rather than reaching it through `react` — the
|
|
176
|
+
host stacks it keeps optional are Playwright and Testing Library, and a filename
|
|
177
|
+
rule is not one of them.
|
|
178
|
+
|
|
179
|
+
**An inline `url()` is spelled in entities.** `style="background-image:url("/a.png")"`
|
|
180
|
+
reaches the scan through HTML serialization, and reading it literally asked the
|
|
181
|
+
caller for bytes at a URL that exists nowhere but in the escaping. The HTML half
|
|
182
|
+
is scanned with the attribute's entities undone; the stylesheet half, which was
|
|
183
|
+
never escaped, is scanned as before.
|
|
184
|
+
|
|
185
|
+
**A stubbed `getComputedStyle` is not a broken asset scan.** Replacing
|
|
186
|
+
`window.getComputedStyle` with a map of the two properties a component reads is
|
|
187
|
+
the only way to drive some layouts in jsdom. The scan called `getPropertyValue`
|
|
188
|
+
on the plain object it got back, and every test in the file died on a `TypeError`
|
|
189
|
+
raised four frames below anything you wrote. There is nothing to scan and nothing
|
|
190
|
+
to complain about — whatever those styles would have named, the stub already
|
|
191
|
+
removed from the page. The attributes on the element are still read.
|
|
192
|
+
|
|
193
|
+
## 0.1.1
|
|
194
|
+
|
|
3
195
|
## 0.1.0
|
|
4
196
|
|
|
5
197
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -26,11 +26,14 @@ input is a PNG.
|
|
|
26
26
|
|
|
27
27
|
## Entrypoints
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Nine groups. Five of them are the order an answer travels through; `core/plan`
|
|
30
30
|
and `core/relate` sit outside that line, because both are asked *before* anything
|
|
31
31
|
is captured — one decides which baselines the run can reach at all, the other
|
|
32
|
-
decides which subjects are worth reaching for.
|
|
33
|
-
|
|
32
|
+
decides which subjects are worth reaching for. `core/segment` and `core/share`
|
|
33
|
+
sit outside it in the other direction: they are how a derived answer is written
|
|
34
|
+
down and handed to another machine, and neither knows what it is carrying. Every
|
|
35
|
+
name lives in exactly one group, and the bare specifier holds only the capture
|
|
36
|
+
artifact.
|
|
34
37
|
|
|
35
38
|
| entrypoint | holds |
|
|
36
39
|
|---|---|
|
|
@@ -41,6 +44,8 @@ seven and is what most callers want.
|
|
|
41
44
|
| `core/judge` | policy: verdicts, intent claims, ignores, the docket a reader is handed |
|
|
42
45
|
| `core/plan` | the whole configuration of a run — profile, ruleset version, viewport, policy, interventions — as one value, plus the identity digest derived from it |
|
|
43
46
|
| `core/relate` | what rests on what: a file graph in adjacency form, the components a change reaches, and a closure digest over each one |
|
|
47
|
+
| `core/segment` | columnar bytes: named columns, interned strings, and the validation a decode performs before it believes a file |
|
|
48
|
+
| `core/share` | leaving those bytes where another machine finds them, under a key that is a commit |
|
|
44
49
|
|
|
45
50
|
Three terms recur across those groups. A **profile** records what a collector
|
|
46
51
|
was capable of observing — jsdom sees structure and declared style, chromium
|
|
@@ -58,7 +63,7 @@ which baselines the run can see.
|
|
|
58
63
|
|
|
59
64
|
## Comparison and policy
|
|
60
65
|
|
|
61
|
-
`compare` says **what
|
|
66
|
+
`compare` says **what changed**. `judge` says **whether anyone should mind**.
|
|
62
67
|
|
|
63
68
|
Between them sits `partingOf`, which says **which input moved**. Given two
|
|
64
69
|
snapshots carrying holdings it walks the component boundaries for the shallowest
|
|
@@ -74,7 +79,8 @@ Value comparison needs no host setup and returns paths and fingerprints, not a
|
|
|
74
79
|
pass/fail verdict:
|
|
75
80
|
|
|
76
81
|
```ts
|
|
77
|
-
import { compareValues
|
|
82
|
+
import { compareValues } from '@variance-authority/core/compare';
|
|
83
|
+
import { shapeValue } from '@variance-authority/core/format';
|
|
78
84
|
|
|
79
85
|
const before = shapeValue({ rows: [{ id: 'a', total: 10 }] }, {
|
|
80
86
|
arrayKey: { '/rows': 'id' },
|
|
@@ -90,7 +96,9 @@ console.log(deltas[0]?.pointer); // /rows/a/total
|
|
|
90
96
|
For a rendered subject, the same package receives captures from a collector:
|
|
91
97
|
|
|
92
98
|
```ts
|
|
93
|
-
import { attributeRegions,
|
|
99
|
+
import { attributeRegions, isolateRegions } from '@variance-authority/core/attribute';
|
|
100
|
+
import { diffSnapshots } from '@variance-authority/core/compare';
|
|
101
|
+
import { normalize } from '@variance-authority/core/rules';
|
|
94
102
|
|
|
95
103
|
const before = normalize(capture); // supplied by a collector
|
|
96
104
|
const after = normalize(recapture);
|
|
@@ -106,7 +114,7 @@ returns semantic roots and, when a raster mask is supplied, regions attributed t
|
|
|
106
114
|
the candidate snapshot. A caller still chooses policy and a verdict in
|
|
107
115
|
`core/judge` or in its own runner.
|
|
108
116
|
|
|
109
|
-
`scale` is device pixels per CSS pixel
|
|
117
|
+
`scale` is device pixels per CSS pixel. It is **required**, and it has no default
|
|
110
118
|
on purpose: a 2x screenshot attributed at 1x lands every region in the top-left
|
|
111
119
|
quadrant and names the wrong component for each — a full, plausible, entirely
|
|
112
120
|
wrong report. That last step is where regions become components and files.
|
|
@@ -119,7 +127,9 @@ The options that change scope are explicit at the call site:
|
|
|
119
127
|
| `isolateRegions` / `attributeRegions` | `cell` and `limit` bound mask work; `origin` and `containment` describe the coordinate origin and how much a node must contain a region |
|
|
120
128
|
| `fingerprintOfMask` | `grid` controls the shape sample and `coverage` controls the minimum occupied share |
|
|
121
129
|
| `compareLocales` / ignore validation | `slack` permits a declared locale distance; `sites` supplies resolved ignore locations and `now` evaluates expiry |
|
|
122
|
-
| `buildDocket` / dependency reach | `sampleSize` limits review examples
|
|
130
|
+
| `buildDocket` / dependency reach | `sampleSize` limits review examples; `through` selects graph edge kinds, `avoid` names nodes a walk never enters, and `shadows` carries per file the modules its run never reaches, so `movedBy` leaves out a file every trail to which crosses one of its own shadows |
|
|
131
|
+
| `sharedClosures` | `floor` drops a shared subtree below a node count |
|
|
132
|
+
| `lexiconOf` | `examples`, `declaredIn` and `regions` supply what the instances cannot: which components a subject is the example of, the files declaring each component, and the regions its journey entered |
|
|
123
133
|
| report summaries | `source` maps component names to files when `summarizeAdjudication` or `summarizeFindings` needs an actionable path |
|
|
124
134
|
| screenshot stabilization | `animations` and `caret` are explicit intervention settings; omitted means the caller did not assert either intervention |
|
|
125
135
|
|
|
@@ -133,7 +143,8 @@ carrying a JSON Pointer and a fingerprint, and — like everything else here —
|
|
|
133
143
|
verdict.
|
|
134
144
|
|
|
135
145
|
```ts
|
|
136
|
-
import {
|
|
146
|
+
import { compareValues } from '@variance-authority/core/compare';
|
|
147
|
+
import { shapeValue } from '@variance-authority/core/format';
|
|
137
148
|
|
|
138
149
|
const baseline = shapeValue(before, { arrayKey: { '/rows': 'id' } });
|
|
139
150
|
const deltas = compareValues(baseline, shapeValue(after, { arrayKey: { '/rows': 'id' } }));
|
|
@@ -154,6 +165,37 @@ in the record that the next run reads as removed.
|
|
|
154
165
|
|
|
155
166
|
Rendered comparisons also throw when the subjects or observation profiles differ.
|
|
156
167
|
|
|
168
|
+
## Handing an answer to another machine
|
|
169
|
+
|
|
170
|
+
A run derives what the suite is made of, and that is a fact about a commit
|
|
171
|
+
rather than about the run. `core/segment` writes such a thing as columns — equal
|
|
172
|
+
facts encode to equal bytes, which is what lets a transport skip an upload — and
|
|
173
|
+
`core/share` moves the bytes:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { httpShare, shareKey } from '@variance-authority/core/share';
|
|
177
|
+
|
|
178
|
+
const share = httpShare({
|
|
179
|
+
endpoint: 'https://objects.example.com/variance',
|
|
180
|
+
headers: { authorization: `Bearer ${token}` },
|
|
181
|
+
method: 'PUT',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await share.put(shareKey({ project: 'web', artifact: 'suite-index-v1', commit }), bytes);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`endpoint` is a base URL a key is appended to; `headers` is sent on every
|
|
188
|
+
request, which is where a bucket's `Authorization` or a deployment's token goes;
|
|
189
|
+
`method` is the verb a write uses — `PUT` for a bucket, `POST` for a deployment
|
|
190
|
+
that routes on it. A presigned base needs none of the three but the first.
|
|
191
|
+
|
|
192
|
+
**A share never throws.** A miss, an outage, a permission error and a body
|
|
193
|
+
nobody can parse are one outcome: `get` answers `null` and `put` resolves.
|
|
194
|
+
Everything a share holds was derived from a tree and can be derived again, so
|
|
195
|
+
the worst a broken one does is cost the derivation — which also means a failing
|
|
196
|
+
share is indistinguishable from a cold one, and only the wall clock says so.
|
|
197
|
+
[Sharing an evaluation](../../docs/sharing.md) is the operator's side of it.
|
|
198
|
+
|
|
157
199
|
## What it refuses
|
|
158
200
|
|
|
159
201
|
**Absent is not empty.** Not measured, measured as zero, and unobservable are
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type Digest } from '../format/hash.js';
|
|
2
|
+
import type { NodePath, SemanticNode } from '../format/snapshot.js';
|
|
3
|
+
/**
|
|
4
|
+
* A digest per node over everything under it, on the document tree.
|
|
5
|
+
*
|
|
6
|
+
* The component hash deliberately is not this. It covers a component's own
|
|
7
|
+
* nodes and names a hole where a child sits, so that an edit stays local and
|
|
8
|
+
* the page root does not move on every commit. The price is a sentence no such
|
|
9
|
+
* digest can say: *this whole subtree, to the leaf, is the one on three other
|
|
10
|
+
* pages*. A closure says exactly that, and it is content-addressed the way a
|
|
11
|
+
* fingerprint is — position removed, so the same bytes at two paths in two
|
|
12
|
+
* subjects are one key — which is what lets *where else is this* become a
|
|
13
|
+
* lookup rather than a comparison.
|
|
14
|
+
*
|
|
15
|
+
* Two closures per node, at two grains of the same tree. `structure` is the
|
|
16
|
+
* tag and the children's structure closures and nothing else: the join key,
|
|
17
|
+
* because two subtrees with different shapes have nothing further to compare.
|
|
18
|
+
* `semantics` is the same walk carrying what the semantics band keeps for each
|
|
19
|
+
* node — role, accessible name, state, the surviving attributes, the text — and
|
|
20
|
+
* it qualifies a match rather than making one.
|
|
21
|
+
*
|
|
22
|
+
* ## What an id contributes
|
|
23
|
+
*
|
|
24
|
+
* An identical widget with a different generated id is the case this tree is
|
|
25
|
+
* for, so an id contributes its presence and never its value. A reference to
|
|
26
|
+
* one is a fact about a *pair* of nodes, and it enters the closure of the
|
|
27
|
+
* lowest node holding both — as the two paths relative to that node, which is
|
|
28
|
+
* position-free with respect to everything outside it. A label and the input
|
|
29
|
+
* it names read the same on every page; a label naming something outside the
|
|
30
|
+
* subtree stays an unbound reference at every closure up to the one that
|
|
31
|
+
* contains its target, and at the root if nothing does.
|
|
32
|
+
*
|
|
33
|
+
* That is the one place a closure cannot be a plain fold of its children's
|
|
34
|
+
* digests: whether a reference is bound is not a property of the node carrying
|
|
35
|
+
* it. So each subtree also carries up what it defines and what it has not yet
|
|
36
|
+
* resolved, and a binding is hashed once, where it closes.
|
|
37
|
+
*
|
|
38
|
+
* ## What enters no closure
|
|
39
|
+
*
|
|
40
|
+
* Style, geometry, tokens, wiring, holding, provenance. A closure is a
|
|
41
|
+
* statement about what was rendered, and it is computed by a run over what it
|
|
42
|
+
* already collected — it enters no baseline and no stored digest.
|
|
43
|
+
*/
|
|
44
|
+
export interface NodeClosure {
|
|
45
|
+
readonly path: NodePath;
|
|
46
|
+
readonly tag: string;
|
|
47
|
+
/** Tag and the children's structure closures, nothing else. */
|
|
48
|
+
readonly structure: Digest;
|
|
49
|
+
/** The structure walk, carrying what the semantics band keeps per node. */
|
|
50
|
+
readonly semantics: Digest;
|
|
51
|
+
/** Nodes under this one, itself included. */
|
|
52
|
+
readonly nodes: number;
|
|
53
|
+
}
|
|
54
|
+
/** One closure per node, in document order. */
|
|
55
|
+
export declare function nodeClosures(root: SemanticNode): readonly NodeClosure[];
|
|
56
|
+
/** One subject's document tree, named. */
|
|
57
|
+
export interface SubjectTree {
|
|
58
|
+
readonly subject: string;
|
|
59
|
+
readonly root: SemanticNode;
|
|
60
|
+
}
|
|
61
|
+
export interface ClosureSite {
|
|
62
|
+
readonly subject: string;
|
|
63
|
+
readonly path: NodePath;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* One structure closure that recurs across subjects, at the shallowest node
|
|
67
|
+
* where it does.
|
|
68
|
+
*/
|
|
69
|
+
export interface SharedClosure {
|
|
70
|
+
readonly structure: Digest;
|
|
71
|
+
readonly tag: string;
|
|
72
|
+
/** Nodes under each site, the site included; every site has the same count. */
|
|
73
|
+
readonly nodes: number;
|
|
74
|
+
/** Sorted by subject, then path. Spans at least two subjects. */
|
|
75
|
+
readonly sites: readonly ClosureSite[];
|
|
76
|
+
/**
|
|
77
|
+
* `held` when every site agrees on the semantics closure too — the same
|
|
78
|
+
* shape with the same content. `parted` is the same shape carrying different
|
|
79
|
+
* content, which is what the two grains exist to keep apart.
|
|
80
|
+
*/
|
|
81
|
+
readonly semantics: 'held' | 'parted';
|
|
82
|
+
}
|
|
83
|
+
export interface SharedClosureOptions {
|
|
84
|
+
/**
|
|
85
|
+
* The smallest subtree worth reporting, in nodes. A shared `<span>` is true
|
|
86
|
+
* and says nothing; the floor is the report's to choose, and absent means
|
|
87
|
+
* every size.
|
|
88
|
+
*/
|
|
89
|
+
readonly floor?: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Every structure closure that recurs in more than one subject, reported once
|
|
93
|
+
* and at its widest.
|
|
94
|
+
*
|
|
95
|
+
* The two-subject rule is composition's: three identical rows in one list say
|
|
96
|
+
* nothing, and the same rows in two subjects say that two diffs are one review.
|
|
97
|
+
*
|
|
98
|
+
* A site is dropped when its parent recurs over exactly the same partners — the
|
|
99
|
+
* rule that makes an echo's example the shallowest boundary, applied to every
|
|
100
|
+
* node. A whole page rendered twice is one entry at the root, not one per
|
|
101
|
+
* element. A subtree that recurs in *more* places than its parent does is kept,
|
|
102
|
+
* because the extra places are the finding.
|
|
103
|
+
*/
|
|
104
|
+
export declare function sharedClosures(subjects: readonly SubjectTree[], options?: SharedClosureOptions): readonly SharedClosure[];
|
|
105
|
+
//# sourceMappingURL=closure.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { digestCombine, digestString, digestValue } from '../format/hash.js';
|
|
2
|
+
import { ID_REFERENCE_ATTRIBUTES, ID_REFERENCE_LIST_ATTRIBUTES } from '../rules/ruleset.js';
|
|
3
|
+
/** One closure per node, in document order. */
|
|
4
|
+
export function nodeClosures(root) {
|
|
5
|
+
const found = [];
|
|
6
|
+
const flatten = (closed) => {
|
|
7
|
+
found.push(closed.closure);
|
|
8
|
+
for (const child of closed.children)
|
|
9
|
+
flatten(child);
|
|
10
|
+
};
|
|
11
|
+
flatten(close(root));
|
|
12
|
+
return found;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every structure closure that recurs in more than one subject, reported once
|
|
16
|
+
* and at its widest.
|
|
17
|
+
*
|
|
18
|
+
* The two-subject rule is composition's: three identical rows in one list say
|
|
19
|
+
* nothing, and the same rows in two subjects say that two diffs are one review.
|
|
20
|
+
*
|
|
21
|
+
* A site is dropped when its parent recurs over exactly the same partners — the
|
|
22
|
+
* rule that makes an echo's example the shallowest boundary, applied to every
|
|
23
|
+
* node. A whole page rendered twice is one entry at the root, not one per
|
|
24
|
+
* element. A subtree that recurs in *more* places than its parent does is kept,
|
|
25
|
+
* because the extra places are the finding.
|
|
26
|
+
*/
|
|
27
|
+
export function sharedClosures(subjects, options = {}) {
|
|
28
|
+
const index = new Map();
|
|
29
|
+
const file = (subject, closed, parent) => {
|
|
30
|
+
const { closure } = closed;
|
|
31
|
+
let entry = index.get(closure.structure);
|
|
32
|
+
if (entry === undefined) {
|
|
33
|
+
entry = { tag: closure.tag, nodes: closure.nodes, sites: [] };
|
|
34
|
+
index.set(closure.structure, entry);
|
|
35
|
+
}
|
|
36
|
+
entry.sites.push({ subject, path: closure.path, semantics: closure.semantics, parent });
|
|
37
|
+
const here = { structure: closure.structure, site: `${subject}\u0000${closure.path}` };
|
|
38
|
+
for (const child of closed.children)
|
|
39
|
+
file(subject, child, here);
|
|
40
|
+
};
|
|
41
|
+
for (const { subject, root } of subjects)
|
|
42
|
+
file(subject, close(root), undefined);
|
|
43
|
+
const shared = [];
|
|
44
|
+
for (const [structure, entry] of index) {
|
|
45
|
+
if (entry.nodes < (options.floor ?? 0))
|
|
46
|
+
continue;
|
|
47
|
+
if (new Set(entry.sites.map((site) => site.subject)).size < 2)
|
|
48
|
+
continue;
|
|
49
|
+
if (subsumed(entry, index))
|
|
50
|
+
continue;
|
|
51
|
+
const sites = entry.sites
|
|
52
|
+
.map(({ subject, path }) => ({ subject, path }))
|
|
53
|
+
.sort((left, right) => codeUnitOrder(left.subject, right.subject) || codeUnitOrder(left.path, right.path));
|
|
54
|
+
const held = new Set(entry.sites.map((site) => site.semantics)).size === 1;
|
|
55
|
+
shared.push({ structure, tag: entry.tag, nodes: entry.nodes, sites, semantics: held ? 'held' : 'parted' });
|
|
56
|
+
}
|
|
57
|
+
return shared.sort((left, right) => codeUnitOrder(left.structure, right.structure));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether every site sits under one recurring parent, and that parent recurs
|
|
61
|
+
* nowhere else. Two identical rows under one `<tbody>` are one parent site, so
|
|
62
|
+
* the parents are counted distinct: the rows are subsumed by the body, and the
|
|
63
|
+
* body by the table above it, up to the node that recurs on its own.
|
|
64
|
+
*/
|
|
65
|
+
function subsumed(entry, index) {
|
|
66
|
+
const first = entry.sites[0]?.parent;
|
|
67
|
+
if (first === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
const parents = new Set();
|
|
70
|
+
for (const site of entry.sites) {
|
|
71
|
+
if (site.parent === undefined || site.parent.structure !== first.structure)
|
|
72
|
+
return false;
|
|
73
|
+
parents.add(site.parent.site);
|
|
74
|
+
}
|
|
75
|
+
return index.get(first.structure)?.sites.length === parents.size;
|
|
76
|
+
}
|
|
77
|
+
function close(node) {
|
|
78
|
+
const children = node.children.map((child) => close(child));
|
|
79
|
+
const defined = new Map();
|
|
80
|
+
if (node.alias !== undefined)
|
|
81
|
+
defined.set(node.alias, '');
|
|
82
|
+
children.forEach((child, index) => {
|
|
83
|
+
for (const [alias, path] of child.defined)
|
|
84
|
+
defined.set(alias, under(index, path));
|
|
85
|
+
});
|
|
86
|
+
const bindings = [];
|
|
87
|
+
const unbound = [];
|
|
88
|
+
const settle = (reference) => {
|
|
89
|
+
const to = defined.get(reference.alias);
|
|
90
|
+
if (to === undefined)
|
|
91
|
+
unbound.push(reference);
|
|
92
|
+
else
|
|
93
|
+
bindings.push({ attribute: reference.attribute, from: reference.from, to });
|
|
94
|
+
};
|
|
95
|
+
for (const reference of referencesOf(node))
|
|
96
|
+
settle(reference);
|
|
97
|
+
children.forEach((child, index) => {
|
|
98
|
+
for (const reference of child.unbound)
|
|
99
|
+
settle({ ...reference, from: under(index, reference.from) });
|
|
100
|
+
});
|
|
101
|
+
bindings.sort((left, right) => codeUnitOrder(left.from, right.from) ||
|
|
102
|
+
codeUnitOrder(left.attribute, right.attribute) ||
|
|
103
|
+
codeUnitOrder(left.to, right.to));
|
|
104
|
+
let nodes = 1;
|
|
105
|
+
for (const child of children)
|
|
106
|
+
nodes += child.closure.nodes;
|
|
107
|
+
const structure = digestCombine('closure:structure', [
|
|
108
|
+
digestString(node.tag),
|
|
109
|
+
...children.map((child) => child.closure.structure),
|
|
110
|
+
]);
|
|
111
|
+
const semantics = digestCombine('closure:semantics', [
|
|
112
|
+
digestValue({ own: ownSemantics(node), bindings }),
|
|
113
|
+
...children.map((child) => child.closure.semantics),
|
|
114
|
+
]);
|
|
115
|
+
return {
|
|
116
|
+
closure: { path: node.path, tag: node.tag, structure, semantics, nodes },
|
|
117
|
+
children,
|
|
118
|
+
defined,
|
|
119
|
+
unbound,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** A relative path one level down: the child's index, then the path beneath it. */
|
|
123
|
+
function under(index, path) {
|
|
124
|
+
return path === '' ? String(index) : `${index}/${path}`;
|
|
125
|
+
}
|
|
126
|
+
const REFERENCE = new Set(ID_REFERENCE_ATTRIBUTES);
|
|
127
|
+
const REFERENCE_LIST = new Set(ID_REFERENCE_LIST_ATTRIBUTES);
|
|
128
|
+
/** What normalization turned an id into; an `extern:` alias lies outside every subtree. */
|
|
129
|
+
const ALIAS = /^#(?:a\d+|extern:.*)$/;
|
|
130
|
+
/**
|
|
131
|
+
* What the semantics band keeps for one node, with every id reduced to
|
|
132
|
+
* presence. `null` rather than omitted where a node has nothing, for the reason
|
|
133
|
+
* every band gives: an omitted entry lets two different nodes agree by
|
|
134
|
+
* coincidence.
|
|
135
|
+
*/
|
|
136
|
+
function ownSemantics(node) {
|
|
137
|
+
const attributes = {};
|
|
138
|
+
for (const [name, value] of Object.entries(node.attributes)) {
|
|
139
|
+
attributes[name] = isReference(name) ? withoutAliases(value) : value;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
tag: node.tag,
|
|
143
|
+
id: node.alias !== undefined,
|
|
144
|
+
role: node.role ?? null,
|
|
145
|
+
name: node.name ?? null,
|
|
146
|
+
state: (node.state ?? null),
|
|
147
|
+
attributes,
|
|
148
|
+
text: node.text ?? null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Every alias this node refers to, each as a reference from the node itself. */
|
|
152
|
+
function referencesOf(node) {
|
|
153
|
+
const found = [];
|
|
154
|
+
for (const [attribute, value] of Object.entries(node.attributes)) {
|
|
155
|
+
if (!isReference(attribute))
|
|
156
|
+
continue;
|
|
157
|
+
for (const alias of aliasesIn(attribute, value))
|
|
158
|
+
found.push({ attribute, alias, from: '' });
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
function isReference(name) {
|
|
163
|
+
return REFERENCE.has(name) || name === 'href' || name === 'xlink:href';
|
|
164
|
+
}
|
|
165
|
+
function aliasesIn(name, value) {
|
|
166
|
+
const parts = REFERENCE_LIST.has(name) ? value.split(/\s+/) : [value];
|
|
167
|
+
return parts.filter((part) => ALIAS.test(part));
|
|
168
|
+
}
|
|
169
|
+
/** The attribute with each alias replaced by a marker, so the binding carries the pair and the value does not. */
|
|
170
|
+
function withoutAliases(value) {
|
|
171
|
+
return value
|
|
172
|
+
.split(/\s+/)
|
|
173
|
+
.map((part) => (ALIAS.test(part) ? '#' : part))
|
|
174
|
+
.join(' ');
|
|
175
|
+
}
|
|
176
|
+
/** The order every other list in this package is sorted by. */
|
|
177
|
+
function codeUnitOrder(left, right) {
|
|
178
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=closure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"closure.js","sourceRoot":"","sources":["../../src/attribute/closure.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CanonicalValue } from '../format/canonical.js';\nimport { digestCombine, digestString, digestValue, type Digest } from '../format/hash.js';\nimport type { NodePath, SemanticNode } from '../format/snapshot.js';\nimport { ID_REFERENCE_ATTRIBUTES, ID_REFERENCE_LIST_ATTRIBUTES } from '../rules/ruleset.js';\n\n/**\n * A digest per node over everything under it, on the document tree.\n *\n * The component hash deliberately is not this. It covers a component's own\n * nodes and names a hole where a child sits, so that an edit stays local and\n * the page root does not move on every commit. The price is a sentence no such\n * digest can say: *this whole subtree, to the leaf, is the one on three other\n * pages*. A closure says exactly that, and it is content-addressed the way a\n * fingerprint is — position removed, so the same bytes at two paths in two\n * subjects are one key — which is what lets *where else is this* become a\n * lookup rather than a comparison.\n *\n * Two closures per node, at two grains of the same tree. `structure` is the\n * tag and the children's structure closures and nothing else: the join key,\n * because two subtrees with different shapes have nothing further to compare.\n * `semantics` is the same walk carrying what the semantics band keeps for each\n * node — role, accessible name, state, the surviving attributes, the text — and\n * it qualifies a match rather than making one.\n *\n * ## What an id contributes\n *\n * An identical widget with a different generated id is the case this tree is\n * for, so an id contributes its presence and never its value. A reference to\n * one is a fact about a *pair* of nodes, and it enters the closure of the\n * lowest node holding both — as the two paths relative to that node, which is\n * position-free with respect to everything outside it. A label and the input\n * it names read the same on every page; a label naming something outside the\n * subtree stays an unbound reference at every closure up to the one that\n * contains its target, and at the root if nothing does.\n *\n * That is the one place a closure cannot be a plain fold of its children's\n * digests: whether a reference is bound is not a property of the node carrying\n * it. So each subtree also carries up what it defines and what it has not yet\n * resolved, and a binding is hashed once, where it closes.\n *\n * ## What enters no closure\n *\n * Style, geometry, tokens, wiring, holding, provenance. A closure is a\n * statement about what was rendered, and it is computed by a run over what it\n * already collected — it enters no baseline and no stored digest.\n */\nexport interface NodeClosure {\n readonly path: NodePath;\n readonly tag: string;\n /** Tag and the children's structure closures, nothing else. */\n readonly structure: Digest;\n /** The structure walk, carrying what the semantics band keeps per node. */\n readonly semantics: Digest;\n /** Nodes under this one, itself included. */\n readonly nodes: number;\n}\n\n/** One closure per node, in document order. */\nexport function nodeClosures(root: SemanticNode): readonly NodeClosure[] {\n const found: NodeClosure[] = [];\n const flatten = (closed: Closed): void => {\n found.push(closed.closure);\n for (const child of closed.children) flatten(child);\n };\n flatten(close(root));\n return found;\n}\n\n/** One subject's document tree, named. */\nexport interface SubjectTree {\n readonly subject: string;\n readonly root: SemanticNode;\n}\n\nexport interface ClosureSite {\n readonly subject: string;\n readonly path: NodePath;\n}\n\n/**\n * One structure closure that recurs across subjects, at the shallowest node\n * where it does.\n */\nexport interface SharedClosure {\n readonly structure: Digest;\n readonly tag: string;\n /** Nodes under each site, the site included; every site has the same count. */\n readonly nodes: number;\n /** Sorted by subject, then path. Spans at least two subjects. */\n readonly sites: readonly ClosureSite[];\n /**\n * `held` when every site agrees on the semantics closure too — the same\n * shape with the same content. `parted` is the same shape carrying different\n * content, which is what the two grains exist to keep apart.\n */\n readonly semantics: 'held' | 'parted';\n}\n\nexport interface SharedClosureOptions {\n /**\n * The smallest subtree worth reporting, in nodes. A shared `<span>` is true\n * and says nothing; the floor is the report's to choose, and absent means\n * every size.\n */\n readonly floor?: number;\n}\n\n/**\n * Every structure closure that recurs in more than one subject, reported once\n * and at its widest.\n *\n * The two-subject rule is composition's: three identical rows in one list say\n * nothing, and the same rows in two subjects say that two diffs are one review.\n *\n * A site is dropped when its parent recurs over exactly the same partners — the\n * rule that makes an echo's example the shallowest boundary, applied to every\n * node. A whole page rendered twice is one entry at the root, not one per\n * element. A subtree that recurs in *more* places than its parent does is kept,\n * because the extra places are the finding.\n */\nexport function sharedClosures(\n subjects: readonly SubjectTree[],\n options: SharedClosureOptions = {},\n): readonly SharedClosure[] {\n const index = new Map<Digest, Entry>();\n\n const file = (subject: string, closed: Closed, parent: Parent | undefined): void => {\n const { closure } = closed;\n let entry = index.get(closure.structure);\n if (entry === undefined) {\n entry = { tag: closure.tag, nodes: closure.nodes, sites: [] };\n index.set(closure.structure, entry);\n }\n entry.sites.push({ subject, path: closure.path, semantics: closure.semantics, parent });\n const here = { structure: closure.structure, site: `${subject}\\u0000${closure.path}` };\n for (const child of closed.children) file(subject, child, here);\n };\n\n for (const { subject, root } of subjects) file(subject, close(root), undefined);\n\n const shared: SharedClosure[] = [];\n for (const [structure, entry] of index) {\n if (entry.nodes < (options.floor ?? 0)) continue;\n if (new Set(entry.sites.map((site) => site.subject)).size < 2) continue;\n if (subsumed(entry, index)) continue;\n\n const sites = entry.sites\n .map(({ subject, path }) => ({ subject, path }))\n .sort((left, right) => codeUnitOrder(left.subject, right.subject) || codeUnitOrder(left.path, right.path));\n const held = new Set(entry.sites.map((site) => site.semantics)).size === 1;\n\n shared.push({ structure, tag: entry.tag, nodes: entry.nodes, sites, semantics: held ? 'held' : 'parted' });\n }\n\n return shared.sort((left, right) => codeUnitOrder(left.structure, right.structure));\n}\n\ninterface Parent {\n readonly structure: Digest;\n /** Subject and path, so that two rows under one parent count that parent once. */\n readonly site: string;\n}\n\ninterface Entry {\n readonly tag: string;\n readonly nodes: number;\n readonly sites: {\n readonly subject: string;\n readonly path: NodePath;\n readonly semantics: Digest;\n readonly parent: Parent | undefined;\n }[];\n}\n\n/**\n * Whether every site sits under one recurring parent, and that parent recurs\n * nowhere else. Two identical rows under one `<tbody>` are one parent site, so\n * the parents are counted distinct: the rows are subsumed by the body, and the\n * body by the table above it, up to the node that recurs on its own.\n */\nfunction subsumed(entry: Entry, index: ReadonlyMap<Digest, Entry>): boolean {\n const first = entry.sites[0]?.parent;\n if (first === undefined) return false;\n const parents = new Set<string>();\n for (const site of entry.sites) {\n if (site.parent === undefined || site.parent.structure !== first.structure) return false;\n parents.add(site.parent.site);\n }\n return index.get(first.structure)?.sites.length === parents.size;\n}\n\ninterface Closed {\n readonly closure: NodeClosure;\n readonly children: readonly Closed[];\n /** Alias → path relative to this node, for every id defined in this subtree. */\n readonly defined: ReadonlyMap<string, string>;\n /** References made in this subtree whose target is not in it. */\n readonly unbound: readonly Reference[];\n}\n\ninterface Reference {\n readonly attribute: string;\n readonly alias: string;\n /** Path of the referring node, relative to the subtree carrying this. */\n readonly from: string;\n}\n\n/** A reference and its target, as two paths relative to the node they close under. */\ntype Binding = {\n readonly attribute: string;\n readonly from: string;\n readonly to: string;\n};\n\nfunction close(node: SemanticNode): Closed {\n const children = node.children.map((child) => close(child));\n\n const defined = new Map<string, string>();\n if (node.alias !== undefined) defined.set(node.alias, '');\n children.forEach((child, index) => {\n for (const [alias, path] of child.defined) defined.set(alias, under(index, path));\n });\n\n const bindings: Binding[] = [];\n const unbound: Reference[] = [];\n const settle = (reference: Reference): void => {\n const to = defined.get(reference.alias);\n if (to === undefined) unbound.push(reference);\n else bindings.push({ attribute: reference.attribute, from: reference.from, to });\n };\n for (const reference of referencesOf(node)) settle(reference);\n children.forEach((child, index) => {\n for (const reference of child.unbound) settle({ ...reference, from: under(index, reference.from) });\n });\n bindings.sort(\n (left, right) =>\n codeUnitOrder(left.from, right.from) ||\n codeUnitOrder(left.attribute, right.attribute) ||\n codeUnitOrder(left.to, right.to),\n );\n\n let nodes = 1;\n for (const child of children) nodes += child.closure.nodes;\n\n const structure = digestCombine('closure:structure', [\n digestString(node.tag),\n ...children.map((child) => child.closure.structure),\n ]);\n const semantics = digestCombine('closure:semantics', [\n digestValue({ own: ownSemantics(node), bindings }),\n ...children.map((child) => child.closure.semantics),\n ]);\n\n return {\n closure: { path: node.path, tag: node.tag, structure, semantics, nodes },\n children,\n defined,\n unbound,\n };\n}\n\n/** A relative path one level down: the child's index, then the path beneath it. */\nfunction under(index: number, path: string): string {\n return path === '' ? String(index) : `${index}/${path}`;\n}\n\nconst REFERENCE = new Set(ID_REFERENCE_ATTRIBUTES);\nconst REFERENCE_LIST = new Set(ID_REFERENCE_LIST_ATTRIBUTES);\n/** What normalization turned an id into; an `extern:` alias lies outside every subtree. */\nconst ALIAS = /^#(?:a\\d+|extern:.*)$/;\n\n/**\n * What the semantics band keeps for one node, with every id reduced to\n * presence. `null` rather than omitted where a node has nothing, for the reason\n * every band gives: an omitted entry lets two different nodes agree by\n * coincidence.\n */\nfunction ownSemantics(node: SemanticNode): CanonicalValue {\n const attributes: Record<string, string> = {};\n for (const [name, value] of Object.entries(node.attributes)) {\n attributes[name] = isReference(name) ? withoutAliases(value) : value;\n }\n return {\n tag: node.tag,\n id: node.alias !== undefined,\n role: node.role ?? null,\n name: node.name ?? null,\n state: (node.state ?? null) as CanonicalValue,\n attributes,\n text: node.text ?? null,\n };\n}\n\n/** Every alias this node refers to, each as a reference from the node itself. */\nfunction referencesOf(node: SemanticNode): readonly Reference[] {\n const found: Reference[] = [];\n for (const [attribute, value] of Object.entries(node.attributes)) {\n if (!isReference(attribute)) continue;\n for (const alias of aliasesIn(attribute, value)) found.push({ attribute, alias, from: '' });\n }\n return found;\n}\n\nfunction isReference(name: string): boolean {\n return REFERENCE.has(name) || name === 'href' || name === 'xlink:href';\n}\n\nfunction aliasesIn(name: string, value: string): readonly string[] {\n const parts = REFERENCE_LIST.has(name) ? value.split(/\\s+/) : [value];\n return parts.filter((part) => ALIAS.test(part));\n}\n\n/** The attribute with each alias replaced by a marker, so the binding carries the pair and the value does not. */\nfunction withoutAliases(value: string): string {\n return value\n .split(/\\s+/)\n .map((part) => (ALIAS.test(part) ? '#' : part))\n .join(' ');\n}\n\n/** The order every other list in this package is sorted by. */\nfunction codeUnitOrder(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"]}
|