@variance-authority/core 0.1.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 +79 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/artifact.d.ts +47 -0
- package/dist/artifact.js +2 -0
- package/dist/artifact.js.map +1 -0
- package/dist/attribute/because.d.ts +43 -0
- package/dist/attribute/because.js +64 -0
- package/dist/attribute/because.js.map +1 -0
- package/dist/attribute/boundary.d.ts +191 -0
- package/dist/attribute/boundary.js +328 -0
- package/dist/attribute/boundary.js.map +1 -0
- package/dist/attribute/call-site.d.ts +96 -0
- package/dist/attribute/call-site.js +183 -0
- package/dist/attribute/call-site.js.map +1 -0
- package/dist/attribute/component-hash.d.ts +188 -0
- package/dist/attribute/component-hash.js +312 -0
- package/dist/attribute/component-hash.js.map +1 -0
- package/dist/attribute/composition-fixture.d.ts +18 -0
- package/dist/attribute/composition-fixture.js +53 -0
- package/dist/attribute/composition-fixture.js.map +1 -0
- package/dist/attribute/composition.d.ts +182 -0
- package/dist/attribute/composition.js +169 -0
- package/dist/attribute/composition.js.map +1 -0
- package/dist/attribute/control.d.ts +43 -0
- package/dist/attribute/control.js +54 -0
- package/dist/attribute/control.js.map +1 -0
- package/dist/attribute/coverage.d.ts +99 -0
- package/dist/attribute/coverage.js +86 -0
- package/dist/attribute/coverage.js.map +1 -0
- package/dist/attribute/divergence.d.ts +102 -0
- package/dist/attribute/divergence.js +152 -0
- package/dist/attribute/divergence.js.map +1 -0
- package/dist/attribute/index.d.ts +43 -0
- package/dist/attribute/index.js +29 -0
- package/dist/attribute/index.js.map +1 -0
- package/dist/attribute/instability.d.ts +89 -0
- package/dist/attribute/instability.js +183 -0
- package/dist/attribute/instability.js.map +1 -0
- package/dist/attribute/instances.d.ts +145 -0
- package/dist/attribute/instances.js +140 -0
- package/dist/attribute/instances.js.map +1 -0
- package/dist/attribute/locate.d.ts +22 -0
- package/dist/attribute/locate.js +100 -0
- package/dist/attribute/locate.js.map +1 -0
- package/dist/attribute/mask.d.ts +156 -0
- package/dist/attribute/mask.js +201 -0
- package/dist/attribute/mask.js.map +1 -0
- package/dist/attribute/movement.d.ts +211 -0
- package/dist/attribute/movement.js +181 -0
- package/dist/attribute/movement.js.map +1 -0
- package/dist/attribute/region.d.ts +188 -0
- package/dist/attribute/region.js +144 -0
- package/dist/attribute/region.js.map +1 -0
- package/dist/attribute/source-map.d.ts +98 -0
- package/dist/attribute/source-map.js +264 -0
- package/dist/attribute/source-map.js.map +1 -0
- package/dist/attribute/source.d.ts +77 -0
- package/dist/attribute/source.js +95 -0
- package/dist/attribute/source.js.map +1 -0
- package/dist/attribute/stack.d.ts +80 -0
- package/dist/attribute/stack.js +108 -0
- package/dist/attribute/stack.js.map +1 -0
- package/dist/compare/band.d.ts +85 -0
- package/dist/compare/band.js +70 -0
- package/dist/compare/band.js.map +1 -0
- package/dist/compare/cascade.d.ts +45 -0
- package/dist/compare/cascade.js +71 -0
- package/dist/compare/cascade.js.map +1 -0
- package/dist/compare/derive.d.ts +78 -0
- package/dist/compare/derive.js +39 -0
- package/dist/compare/derive.js.map +1 -0
- package/dist/compare/diff/attribution.d.ts +23 -0
- package/dist/compare/diff/attribution.js +244 -0
- package/dist/compare/diff/attribution.js.map +1 -0
- package/dist/compare/diff/compare-nodes.d.ts +15 -0
- package/dist/compare/diff/compare-nodes.js +227 -0
- package/dist/compare/diff/compare-nodes.js.map +1 -0
- package/dist/compare/diff/components.d.ts +20 -0
- package/dist/compare/diff/components.js +76 -0
- package/dist/compare/diff/components.js.map +1 -0
- package/dist/compare/diff/delta.d.ts +165 -0
- package/dist/compare/diff/delta.js +12 -0
- package/dist/compare/diff/delta.js.map +1 -0
- package/dist/compare/diff/index.d.ts +69 -0
- package/dist/compare/diff/index.js +101 -0
- package/dist/compare/diff/index.js.map +1 -0
- package/dist/compare/diff/match.d.ts +37 -0
- package/dist/compare/diff/match.js +167 -0
- package/dist/compare/diff/match.js.map +1 -0
- package/dist/compare/explain.d.ts +23 -0
- package/dist/compare/explain.js +190 -0
- package/dist/compare/explain.js.map +1 -0
- package/dist/compare/holding-diff.d.ts +25 -0
- package/dist/compare/holding-diff.js +74 -0
- package/dist/compare/holding-diff.js.map +1 -0
- package/dist/compare/impact.d.ts +56 -0
- package/dist/compare/impact.js +124 -0
- package/dist/compare/impact.js.map +1 -0
- package/dist/compare/index.d.ts +30 -0
- package/dist/compare/index.js +22 -0
- package/dist/compare/index.js.map +1 -0
- package/dist/compare/instance.d.ts +30 -0
- package/dist/compare/instance.js +145 -0
- package/dist/compare/instance.js.map +1 -0
- package/dist/compare/observability.d.ts +49 -0
- package/dist/compare/observability.js +57 -0
- package/dist/compare/observability.js.map +1 -0
- package/dist/compare/parting.d.ts +179 -0
- package/dist/compare/parting.js +244 -0
- package/dist/compare/parting.js.map +1 -0
- package/dist/compare/slice.d.ts +74 -0
- package/dist/compare/slice.js +79 -0
- package/dist/compare/slice.js.map +1 -0
- package/dist/compare/value.d.ts +60 -0
- package/dist/compare/value.js +111 -0
- package/dist/compare/value.js.map +1 -0
- package/dist/format/accessibility.d.ts +27 -0
- package/dist/format/accessibility.js +17 -0
- package/dist/format/accessibility.js.map +1 -0
- package/dist/format/canonical.d.ts +38 -0
- package/dist/format/canonical.js +65 -0
- package/dist/format/canonical.js.map +1 -0
- package/dist/format/capture.d.ts +212 -0
- package/dist/format/capture.js +2 -0
- package/dist/format/capture.js.map +1 -0
- package/dist/format/document.d.ts +279 -0
- package/dist/format/document.js +65 -0
- package/dist/format/document.js.map +1 -0
- package/dist/format/environment.d.ts +120 -0
- package/dist/format/environment.js +53 -0
- package/dist/format/environment.js.map +1 -0
- package/dist/format/hash.d.ts +45 -0
- package/dist/format/hash.js +41 -0
- package/dist/format/hash.js.map +1 -0
- package/dist/format/holding.d.ts +134 -0
- package/dist/format/holding.js +2 -0
- package/dist/format/holding.js.map +1 -0
- package/dist/format/index.d.ts +39 -0
- package/dist/format/index.js +25 -0
- package/dist/format/index.js.map +1 -0
- package/dist/format/intervention.d.ts +87 -0
- package/dist/format/intervention.js +11 -0
- package/dist/format/intervention.js.map +1 -0
- package/dist/format/profile.d.ts +59 -0
- package/dist/format/profile.js +66 -0
- package/dist/format/profile.js.map +1 -0
- package/dist/format/provenance.d.ts +179 -0
- package/dist/format/provenance.js +222 -0
- package/dist/format/provenance.js.map +1 -0
- package/dist/format/sha256.d.ts +34 -0
- package/dist/format/sha256.js +152 -0
- package/dist/format/sha256.js.map +1 -0
- package/dist/format/snapshot.d.ts +263 -0
- package/dist/format/snapshot.js +2 -0
- package/dist/format/snapshot.js.map +1 -0
- package/dist/format/stabilize.d.ts +190 -0
- package/dist/format/stabilize.js +333 -0
- package/dist/format/stabilize.js.map +1 -0
- package/dist/format/tier.d.ts +19 -0
- package/dist/format/tier.js +11 -0
- package/dist/format/tier.js.map +1 -0
- package/dist/format/value.d.ts +115 -0
- package/dist/format/value.js +182 -0
- package/dist/format/value.js.map +1 -0
- package/dist/format/wiring.d.ts +115 -0
- package/dist/format/wiring.js +60 -0
- package/dist/format/wiring.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/judge/docket.d.ts +85 -0
- package/dist/judge/docket.js +162 -0
- package/dist/judge/docket.js.map +1 -0
- package/dist/judge/fingerprint.d.ts +53 -0
- package/dist/judge/fingerprint.js +144 -0
- package/dist/judge/fingerprint.js.map +1 -0
- package/dist/judge/ignore.d.ts +206 -0
- package/dist/judge/ignore.js +237 -0
- package/dist/judge/ignore.js.map +1 -0
- package/dist/judge/index.d.ts +44 -0
- package/dist/judge/index.js +34 -0
- package/dist/judge/index.js.map +1 -0
- package/dist/judge/inspect.d.ts +148 -0
- package/dist/judge/inspect.js +208 -0
- package/dist/judge/inspect.js.map +1 -0
- package/dist/judge/intent.d.ts +146 -0
- package/dist/judge/intent.js +150 -0
- package/dist/judge/intent.js.map +1 -0
- package/dist/judge/locale.d.ts +111 -0
- package/dist/judge/locale.js +247 -0
- package/dist/judge/locale.js.map +1 -0
- package/dist/judge/scope.d.ts +41 -0
- package/dist/judge/scope.js +47 -0
- package/dist/judge/scope.js.map +1 -0
- package/dist/judge/sensitivity.d.ts +202 -0
- package/dist/judge/sensitivity.js +152 -0
- package/dist/judge/sensitivity.js.map +1 -0
- package/dist/judge/trail.d.ts +122 -0
- package/dist/judge/trail.js +96 -0
- package/dist/judge/trail.js.map +1 -0
- package/dist/judge/verdict.d.ts +39 -0
- package/dist/judge/verdict.js +32 -0
- package/dist/judge/verdict.js.map +1 -0
- package/dist/plan/index.d.ts +12 -0
- package/dist/plan/index.js +11 -0
- package/dist/plan/index.js.map +1 -0
- package/dist/plan/tool.d.ts +85 -0
- package/dist/plan/tool.js +87 -0
- package/dist/plan/tool.js.map +1 -0
- package/dist/relate/graph.d.ts +190 -0
- package/dist/relate/graph.js +234 -0
- package/dist/relate/graph.js.map +1 -0
- package/dist/relate/index.d.ts +16 -0
- package/dist/relate/index.js +16 -0
- package/dist/relate/index.js.map +1 -0
- package/dist/relate/merkle.d.ts +110 -0
- package/dist/relate/merkle.js +246 -0
- package/dist/relate/merkle.js.map +1 -0
- package/dist/relate/reach.d.ts +65 -0
- package/dist/relate/reach.js +111 -0
- package/dist/relate/reach.js.map +1 -0
- package/dist/relate/records.d.ts +129 -0
- package/dist/relate/records.js +112 -0
- package/dist/relate/records.js.map +1 -0
- package/dist/rules/index.d.ts +19 -0
- package/dist/rules/index.js +17 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/normalize/alias.d.ts +50 -0
- package/dist/rules/normalize/alias.js +111 -0
- package/dist/rules/normalize/alias.js.map +1 -0
- package/dist/rules/normalize/cascade.d.ts +85 -0
- package/dist/rules/normalize/cascade.js +324 -0
- package/dist/rules/normalize/cascade.js.map +1 -0
- package/dist/rules/normalize/color.d.ts +30 -0
- package/dist/rules/normalize/color.js +185 -0
- package/dist/rules/normalize/color.js.map +1 -0
- package/dist/rules/normalize/fixture.d.ts +83 -0
- package/dist/rules/normalize/fixture.js +74 -0
- package/dist/rules/normalize/fixture.js.map +1 -0
- package/dist/rules/normalize/index.d.ts +39 -0
- package/dist/rules/normalize/index.js +268 -0
- package/dist/rules/normalize/index.js.map +1 -0
- package/dist/rules/normalize/project.d.ts +42 -0
- package/dist/rules/normalize/project.js +83 -0
- package/dist/rules/normalize/project.js.map +1 -0
- package/dist/rules/normalize/shorthand.d.ts +38 -0
- package/dist/rules/normalize/shorthand.js +388 -0
- package/dist/rules/normalize/shorthand.js.map +1 -0
- package/dist/rules/normalize/value.d.ts +23 -0
- package/dist/rules/normalize/value.js +162 -0
- package/dist/rules/normalize/value.js.map +1 -0
- package/dist/rules/normalize/wrapper.d.ts +16 -0
- package/dist/rules/normalize/wrapper.js +137 -0
- package/dist/rules/normalize/wrapper.js.map +1 -0
- package/dist/rules/ruleset.d.ts +93 -0
- package/dist/rules/ruleset.js +175 -0
- package/dist/rules/ruleset.js.map +1 -0
- package/mark.svg +30 -0
- package/package.json +69 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { type Band } from '../compare/band.js';
|
|
2
|
+
import type { SemanticDiff } from '../compare/diff/index.js';
|
|
3
|
+
import { type IgnoreOptions, type IgnoreRule } from './ignore.js';
|
|
4
|
+
/**
|
|
5
|
+
* How much of a subject is being asserted on, declared where it applies.
|
|
6
|
+
*
|
|
7
|
+
* A route-level test and a component-level test want opposite things from the
|
|
8
|
+
* same machinery. A component's test asserts on everything: a colour token moved
|
|
9
|
+
* and that is the change. A route's test asserts that the *page still assembles*
|
|
10
|
+
* — the nav is where it was, the sidebar did not collapse, nothing overlaps — and
|
|
11
|
+
* a design-system token landing in forty routes is noise it should never have
|
|
12
|
+
* been shown. Run one policy over both and one of them is unusable: either the
|
|
13
|
+
* routes are permanently red, or the components stop reporting the thing they
|
|
14
|
+
* exist to catch.
|
|
15
|
+
*
|
|
16
|
+
* The usual answer is a second product, or a threshold, and both are worse than
|
|
17
|
+
* the problem. What this offers instead is the same shape an
|
|
18
|
+
* {@link IgnoreRule} has, because it is the same act: **naming what is not being
|
|
19
|
+
* asserted on, where, by whom, and with the count in the report.**
|
|
20
|
+
*
|
|
21
|
+
* ## The unit is a band, and that is the whole design
|
|
22
|
+
*
|
|
23
|
+
* The frequency bands already say what *kind* of thing moved — `a11y`,
|
|
24
|
+
* `geometry`, `token`, `content`, `texture` — and they are ordered by how rarely
|
|
25
|
+
* they move and how much it matters when they do. "Assert on layout and ignore
|
|
26
|
+
* small token updates" is not a tolerance, a percentage or a pixel count. It is
|
|
27
|
+
* two band names.
|
|
28
|
+
*
|
|
29
|
+
* That is why this is not a threshold: a threshold absorbs *anything* small
|
|
30
|
+
* enough, and a band absorbs exactly one kind of thing however large it is. A
|
|
31
|
+
* route declared `layout` still reports a nav that moved by one pixel, and never
|
|
32
|
+
* reports a rebrand that repainted every surface on the page.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it is a declaration and not a mode
|
|
35
|
+
*
|
|
36
|
+
* The same reason an ignore is. A run-wide switch is a decision nobody attributes
|
|
37
|
+
* and nobody revisits; a rule with an id, a required reason and a place is one a
|
|
38
|
+
* reviewer can read six months later and disagree with. Everything an ignore owes
|
|
39
|
+
* it owes: a reason, a scope, and a count of what it absorbed — including the
|
|
40
|
+
* count of zero, which is how a declaration that has stopped being true is found.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* A named set of bands worth asserting on.
|
|
44
|
+
*
|
|
45
|
+
* A closed set. Three names cover the cases that have argued for themselves and
|
|
46
|
+
* a fourth would need one; an open set of user-defined levels would put the
|
|
47
|
+
* vocabulary in a config file, where the report cannot explain what a word meant
|
|
48
|
+
* to whoever wrote it.
|
|
49
|
+
*/
|
|
50
|
+
export type Level =
|
|
51
|
+
/**
|
|
52
|
+
* Everything. What a component's own test wants, and the default everywhere.
|
|
53
|
+
*
|
|
54
|
+
* Named rather than left implicit so a subject can be declared strict *back*
|
|
55
|
+
* inside a route that is not — the exception a broad declaration always grows.
|
|
56
|
+
*/
|
|
57
|
+
'strict'
|
|
58
|
+
/**
|
|
59
|
+
* The page still assembles: `a11y` and `geometry`.
|
|
60
|
+
*
|
|
61
|
+
* `geometry` is the obvious half — boxes appeared, vanished, moved or resized.
|
|
62
|
+
* `a11y` is the half that gets left out and should not be: a control that lost
|
|
63
|
+
* its accessible name is a structural regression that happens to repaint
|
|
64
|
+
* nothing, and a route test that ignored it would be asserting on the shape of
|
|
65
|
+
* the page while blind to the shape a screen reader sees.
|
|
66
|
+
*
|
|
67
|
+
* What it absorbs is `token`, `content` and `texture` — a restyle, a copy edit,
|
|
68
|
+
* anti-aliasing. Each is a real change, each is reported as absorbed, and none
|
|
69
|
+
* of them is what a route-level test is for.
|
|
70
|
+
*/
|
|
71
|
+
| 'layout'
|
|
72
|
+
/**
|
|
73
|
+
* What the page *says*: `a11y` and `content`.
|
|
74
|
+
*
|
|
75
|
+
* For a subject whose styling is somebody else's to change — a themed embed, a
|
|
76
|
+
* page inside a design system being actively rebranded — where the assertion
|
|
77
|
+
* worth keeping is that the words and the semantics survived.
|
|
78
|
+
*/
|
|
79
|
+
| 'content';
|
|
80
|
+
/** The bands a level asserts on. Everything else is absorbed and counted. */
|
|
81
|
+
export declare function bandsOf(level: Level): readonly Band[];
|
|
82
|
+
export interface SensitivityRule {
|
|
83
|
+
/** Stable name. Appears in the report and in every count this rule produces. */
|
|
84
|
+
readonly id: string;
|
|
85
|
+
/**
|
|
86
|
+
* Why this subject is not asserted on in full. Required, as an ignore's is.
|
|
87
|
+
*
|
|
88
|
+
* The difference between a policy somebody chose and one that accreted. A
|
|
89
|
+
* route declared `layout` two years ago by somebody who has left is a blind
|
|
90
|
+
* spot with a plausible-looking config entry in front of it.
|
|
91
|
+
*/
|
|
92
|
+
readonly reason: string;
|
|
93
|
+
readonly level: Level;
|
|
94
|
+
/** Subjects this applies to. `*` matches any run of characters. */
|
|
95
|
+
readonly subjects?: readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* Tags the subject must carry, as its own artifact declared them.
|
|
98
|
+
*
|
|
99
|
+
* FIXME: read by nothing. `asIgnore` copies `subjects` and drops this, so a
|
|
100
|
+
* rule scoped only by tags translates to an unscoped ignore and absorbs across
|
|
101
|
+
* every subject in the run — the widest possible reading of the narrowest
|
|
102
|
+
* scope an operator can write.
|
|
103
|
+
*/
|
|
104
|
+
readonly tags?: readonly string[];
|
|
105
|
+
}
|
|
106
|
+
/** What one rule absorbed, per band, across a run. */
|
|
107
|
+
export interface Relaxation {
|
|
108
|
+
readonly rule: string;
|
|
109
|
+
readonly reason: string;
|
|
110
|
+
readonly level: Level;
|
|
111
|
+
/** Deltas absorbed because their band is not asserted on here. */
|
|
112
|
+
readonly absorbed: number;
|
|
113
|
+
readonly subjects: readonly string[];
|
|
114
|
+
/** Which bands were absorbed, so a level can be tightened with evidence. */
|
|
115
|
+
readonly bands: readonly Band[];
|
|
116
|
+
}
|
|
117
|
+
export interface SensitivityRegister {
|
|
118
|
+
readonly relaxations: readonly Relaxation[];
|
|
119
|
+
/**
|
|
120
|
+
* Rules that absorbed nothing in any subject they applied to.
|
|
121
|
+
*
|
|
122
|
+
* The same accounting an ignore's register keeps, and for the same reason: a
|
|
123
|
+
* route declared `layout` that has reported no token change in six months is
|
|
124
|
+
* either a route nothing styles, or a declaration nobody needed. Both are worth
|
|
125
|
+
* a line, and neither is visible without the count.
|
|
126
|
+
*/
|
|
127
|
+
readonly dead: readonly string[];
|
|
128
|
+
readonly totalAbsorbed: number;
|
|
129
|
+
}
|
|
130
|
+
export interface SensitivityOutcome {
|
|
131
|
+
readonly diffs: readonly SemanticDiff[];
|
|
132
|
+
readonly register: SensitivityRegister;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Turn a sensitivity rule into the ignore it is.
|
|
136
|
+
*
|
|
137
|
+
* Not a shortcut — a statement about what these are. A sensitivity names a place
|
|
138
|
+
* and narrows by band, which is exactly the shape `IgnoreRule` already refuses to
|
|
139
|
+
* accept *without* a place, and exactly the shape it accepts with one. Sharing
|
|
140
|
+
* the mechanism means sharing the safety property: the deltas are absorbed by
|
|
141
|
+
* `applyIgnores`, counted per rule, and reported when a rule absorbs nothing.
|
|
142
|
+
*
|
|
143
|
+
* The bands are inverted on the way through, and that inversion is the whole
|
|
144
|
+
* translation. A sensitivity says what it *asserts on*; an ignore says what it
|
|
145
|
+
* *absorbs*. Stating the level positively is what makes a config readable — "this
|
|
146
|
+
* route asserts on layout" rather than "this route ignores token, content and
|
|
147
|
+
* texture" — and the negative form is what the machinery needs.
|
|
148
|
+
*/
|
|
149
|
+
export declare function asIgnore(rule: SensitivityRule): IgnoreRule | null;
|
|
150
|
+
/**
|
|
151
|
+
* The register as the lines a run prints.
|
|
152
|
+
*
|
|
153
|
+
* Absorption is stated in the *positive* form the operator wrote, because that is
|
|
154
|
+
* the sentence they can check: "asserts on layout" is a claim about intent, and
|
|
155
|
+
* "absorbed 412 token deltas" is the evidence for or against it.
|
|
156
|
+
*/
|
|
157
|
+
export declare function summarizeSensitivity(register: SensitivityRegister): readonly string[];
|
|
158
|
+
/**
|
|
159
|
+
* Apply declared sensitivities to a run's diffs.
|
|
160
|
+
*
|
|
161
|
+
* A thin fold over {@link applyIgnores}, and thin on purpose: the absorption, the
|
|
162
|
+
* per-rule counting and the dead-rule reporting are one mechanism, and a second
|
|
163
|
+
* implementation of them would be a second set of bugs in the half of the system
|
|
164
|
+
* whose whole job is not to silently hide things.
|
|
165
|
+
*
|
|
166
|
+
* Rules that resolve to `strict` are dropped before the fold rather than passed
|
|
167
|
+
* through, so a project that declares its default explicitly does not collect a
|
|
168
|
+
* permanently-dead register line for saying so.
|
|
169
|
+
*/
|
|
170
|
+
export declare function applySensitivity(diffs: readonly SemanticDiff[], rules: readonly SensitivityRule[], options?: IgnoreOptions): SensitivityOutcome;
|
|
171
|
+
/**
|
|
172
|
+
* What a level does with a set of bands that moved.
|
|
173
|
+
*
|
|
174
|
+
* The arm for the path that holds no second document. `applySensitivity` folds
|
|
175
|
+
* over deltas, which needs both revisions normalized; a `variance run` compares
|
|
176
|
+
* an image against a stored baseline and has only what that baseline carried —
|
|
177
|
+
* per-component hashes, one digest per band (ADR-0027, ADR-0029's split).
|
|
178
|
+
* `bandsBetween` turns those into the same vocabulary, and this decides against
|
|
179
|
+
* it, so the two paths agree by sharing {@link bandsOf} rather than by
|
|
180
|
+
* inspection.
|
|
181
|
+
*
|
|
182
|
+
* **Empty in means nothing absorbed**, not everything. A run whose pixels moved
|
|
183
|
+
* while every component hash held is raster residue — `texture`, which a
|
|
184
|
+
* document cannot carry and which this must therefore never claim to have
|
|
185
|
+
* decided. Absorbing on an empty set would turn the one band a hash comparison
|
|
186
|
+
* is blind to into the one it silences.
|
|
187
|
+
*/
|
|
188
|
+
export declare function relaxes(level: Level, moved: readonly Band[]): {
|
|
189
|
+
readonly asserted: readonly Band[];
|
|
190
|
+
readonly absorbed: readonly Band[];
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Whether a subject's every moved band falls outside what it is asserted on.
|
|
194
|
+
*
|
|
195
|
+
* The whole subject, because that is the unit a stored baseline can answer for:
|
|
196
|
+
* one image, one verdict. A subject where *some* moved band is asserted on is
|
|
197
|
+
* reported in full — including the bands that would have been absorbed — since
|
|
198
|
+
* a reviewer looking at a nav that moved wants the restyle that came with it,
|
|
199
|
+
* and hiding half a diff is worse than hiding none of it.
|
|
200
|
+
*/
|
|
201
|
+
export declare function absorbsEntirely(level: Level, moved: readonly Band[]): boolean;
|
|
202
|
+
//# sourceMappingURL=sensitivity.d.ts.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { BANDS } from '../compare/band.js';
|
|
2
|
+
import { applyIgnores } from './ignore.js';
|
|
3
|
+
const LEVELS = {
|
|
4
|
+
strict: BANDS,
|
|
5
|
+
layout: ['a11y', 'geometry'],
|
|
6
|
+
content: ['a11y', 'content'],
|
|
7
|
+
};
|
|
8
|
+
/** The bands a level asserts on. Everything else is absorbed and counted. */
|
|
9
|
+
export function bandsOf(level) {
|
|
10
|
+
return LEVELS[level];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Turn a sensitivity rule into the ignore it is.
|
|
14
|
+
*
|
|
15
|
+
* Not a shortcut — a statement about what these are. A sensitivity names a place
|
|
16
|
+
* and narrows by band, which is exactly the shape `IgnoreRule` already refuses to
|
|
17
|
+
* accept *without* a place, and exactly the shape it accepts with one. Sharing
|
|
18
|
+
* the mechanism means sharing the safety property: the deltas are absorbed by
|
|
19
|
+
* `applyIgnores`, counted per rule, and reported when a rule absorbs nothing.
|
|
20
|
+
*
|
|
21
|
+
* The bands are inverted on the way through, and that inversion is the whole
|
|
22
|
+
* translation. A sensitivity says what it *asserts on*; an ignore says what it
|
|
23
|
+
* *absorbs*. Stating the level positively is what makes a config readable — "this
|
|
24
|
+
* route asserts on layout" rather than "this route ignores token, content and
|
|
25
|
+
* texture" — and the negative form is what the machinery needs.
|
|
26
|
+
*/
|
|
27
|
+
export function asIgnore(rule) {
|
|
28
|
+
const asserted = new Set(bandsOf(rule.level));
|
|
29
|
+
const absorbed = BANDS.filter((band) => !asserted.has(band));
|
|
30
|
+
// `strict` asserts on everything, so it absorbs nothing and produces no rule.
|
|
31
|
+
// Returning an ignore with an empty band list would produce a register entry
|
|
32
|
+
// that is permanently dead, which is a line telling an operator to delete the
|
|
33
|
+
// one declaration that is doing nothing wrong.
|
|
34
|
+
if (absorbed.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
id: rule.id,
|
|
38
|
+
reason: rule.reason,
|
|
39
|
+
bands: absorbed,
|
|
40
|
+
// The place is the subject, said rather than left to be inferred from a
|
|
41
|
+
// missing selector — see `IgnoreRule.whole`.
|
|
42
|
+
whole: true,
|
|
43
|
+
...(rule.subjects !== undefined ? { subjects: rule.subjects } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The register as the lines a run prints.
|
|
48
|
+
*
|
|
49
|
+
* Absorption is stated in the *positive* form the operator wrote, because that is
|
|
50
|
+
* the sentence they can check: "asserts on layout" is a claim about intent, and
|
|
51
|
+
* "absorbed 412 token deltas" is the evidence for or against it.
|
|
52
|
+
*/
|
|
53
|
+
export function summarizeSensitivity(register) {
|
|
54
|
+
if (register.relaxations.length === 0)
|
|
55
|
+
return [];
|
|
56
|
+
const lines = [
|
|
57
|
+
`SENSITIVITY — ${register.totalAbsorbed} difference(s) not asserted on, ` +
|
|
58
|
+
`by ${register.relaxations.length} rule(s)`,
|
|
59
|
+
];
|
|
60
|
+
for (const entry of register.relaxations) {
|
|
61
|
+
if (entry.absorbed === 0) {
|
|
62
|
+
lines.push(` [dead] ${entry.rule} — asserts on ${entry.level} across ` +
|
|
63
|
+
`${entry.subjects.length} subject(s) and absorbed nothing (${entry.reason}); ` +
|
|
64
|
+
'nothing here needed relaxing');
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
lines.push(` ${entry.rule} — asserts on ${entry.level}; absorbed ${entry.absorbed} ` +
|
|
68
|
+
`${entry.bands.join('/')} difference(s) in ${entry.subjects.length} subject(s): ` +
|
|
69
|
+
entry.reason);
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Apply declared sensitivities to a run's diffs.
|
|
75
|
+
*
|
|
76
|
+
* A thin fold over {@link applyIgnores}, and thin on purpose: the absorption, the
|
|
77
|
+
* per-rule counting and the dead-rule reporting are one mechanism, and a second
|
|
78
|
+
* implementation of them would be a second set of bugs in the half of the system
|
|
79
|
+
* whose whole job is not to silently hide things.
|
|
80
|
+
*
|
|
81
|
+
* Rules that resolve to `strict` are dropped before the fold rather than passed
|
|
82
|
+
* through, so a project that declares its default explicitly does not collect a
|
|
83
|
+
* permanently-dead register line for saying so.
|
|
84
|
+
*/
|
|
85
|
+
export function applySensitivity(diffs, rules, options = {}) {
|
|
86
|
+
const translated = new Map();
|
|
87
|
+
const ignores = [];
|
|
88
|
+
for (const rule of rules) {
|
|
89
|
+
const ignore = asIgnore(rule);
|
|
90
|
+
if (ignore === null)
|
|
91
|
+
continue;
|
|
92
|
+
translated.set(rule.id, rule);
|
|
93
|
+
ignores.push(ignore);
|
|
94
|
+
}
|
|
95
|
+
const outcome = applyIgnores(diffs, ignores, options);
|
|
96
|
+
const relaxations = [...translated.values()].map((rule) => {
|
|
97
|
+
const absorbed = outcome.register.absorbed.find((entry) => entry.rule === rule.id);
|
|
98
|
+
return {
|
|
99
|
+
rule: rule.id,
|
|
100
|
+
reason: rule.reason,
|
|
101
|
+
level: rule.level,
|
|
102
|
+
absorbed: absorbed?.deltas ?? 0,
|
|
103
|
+
subjects: absorbed?.subjects ?? [],
|
|
104
|
+
bands: absorbed?.bands ?? [],
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
diffs: outcome.diffs,
|
|
109
|
+
register: {
|
|
110
|
+
relaxations,
|
|
111
|
+
dead: relaxations.filter((entry) => entry.absorbed === 0).map((entry) => entry.rule),
|
|
112
|
+
totalAbsorbed: relaxations.reduce((sum, entry) => sum + entry.absorbed, 0),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a level does with a set of bands that moved.
|
|
118
|
+
*
|
|
119
|
+
* The arm for the path that holds no second document. `applySensitivity` folds
|
|
120
|
+
* over deltas, which needs both revisions normalized; a `variance run` compares
|
|
121
|
+
* an image against a stored baseline and has only what that baseline carried —
|
|
122
|
+
* per-component hashes, one digest per band (ADR-0027, ADR-0029's split).
|
|
123
|
+
* `bandsBetween` turns those into the same vocabulary, and this decides against
|
|
124
|
+
* it, so the two paths agree by sharing {@link bandsOf} rather than by
|
|
125
|
+
* inspection.
|
|
126
|
+
*
|
|
127
|
+
* **Empty in means nothing absorbed**, not everything. A run whose pixels moved
|
|
128
|
+
* while every component hash held is raster residue — `texture`, which a
|
|
129
|
+
* document cannot carry and which this must therefore never claim to have
|
|
130
|
+
* decided. Absorbing on an empty set would turn the one band a hash comparison
|
|
131
|
+
* is blind to into the one it silences.
|
|
132
|
+
*/
|
|
133
|
+
export function relaxes(level, moved) {
|
|
134
|
+
const asserting = new Set(bandsOf(level));
|
|
135
|
+
return {
|
|
136
|
+
asserted: moved.filter((band) => asserting.has(band)),
|
|
137
|
+
absorbed: moved.filter((band) => !asserting.has(band)),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether a subject's every moved band falls outside what it is asserted on.
|
|
142
|
+
*
|
|
143
|
+
* The whole subject, because that is the unit a stored baseline can answer for:
|
|
144
|
+
* one image, one verdict. A subject where *some* moved band is asserted on is
|
|
145
|
+
* reported in full — including the bands that would have been absorbed — since
|
|
146
|
+
* a reviewer looking at a nav that moved wants the restyle that came with it,
|
|
147
|
+
* and hiding half a diff is worse than hiding none of it.
|
|
148
|
+
*/
|
|
149
|
+
export function absorbsEntirely(level, moved) {
|
|
150
|
+
return moved.length > 0 && relaxes(level, moved).asserted.length === 0;
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=sensitivity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sensitivity.js","sourceRoot":"","sources":["../../src/judge/sensitivity.ts"],"names":[],"mappings":"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","sourcesContent":["import { BANDS, type Band } from '../compare/band.js';\nimport type { SemanticDiff } from '../compare/diff/index.js';\nimport { applyIgnores, type IgnoreOptions, type IgnoreRule } from './ignore.js';\n\n/**\n * How much of a subject is being asserted on, declared where it applies.\n *\n * A route-level test and a component-level test want opposite things from the\n * same machinery. A component's test asserts on everything: a colour token moved\n * and that is the change. A route's test asserts that the *page still assembles*\n * — the nav is where it was, the sidebar did not collapse, nothing overlaps — and\n * a design-system token landing in forty routes is noise it should never have\n * been shown. Run one policy over both and one of them is unusable: either the\n * routes are permanently red, or the components stop reporting the thing they\n * exist to catch.\n *\n * The usual answer is a second product, or a threshold, and both are worse than\n * the problem. What this offers instead is the same shape an\n * {@link IgnoreRule} has, because it is the same act: **naming what is not being\n * asserted on, where, by whom, and with the count in the report.**\n *\n * ## The unit is a band, and that is the whole design\n *\n * The frequency bands already say what *kind* of thing moved — `a11y`,\n * `geometry`, `token`, `content`, `texture` — and they are ordered by how rarely\n * they move and how much it matters when they do. \"Assert on layout and ignore\n * small token updates\" is not a tolerance, a percentage or a pixel count. It is\n * two band names.\n *\n * That is why this is not a threshold: a threshold absorbs *anything* small\n * enough, and a band absorbs exactly one kind of thing however large it is. A\n * route declared `layout` still reports a nav that moved by one pixel, and never\n * reports a rebrand that repainted every surface on the page.\n *\n * ## Why it is a declaration and not a mode\n *\n * The same reason an ignore is. A run-wide switch is a decision nobody attributes\n * and nobody revisits; a rule with an id, a required reason and a place is one a\n * reviewer can read six months later and disagree with. Everything an ignore owes\n * it owes: a reason, a scope, and a count of what it absorbed — including the\n * count of zero, which is how a declaration that has stopped being true is found.\n */\n\n/**\n * A named set of bands worth asserting on.\n *\n * A closed set. Three names cover the cases that have argued for themselves and\n * a fourth would need one; an open set of user-defined levels would put the\n * vocabulary in a config file, where the report cannot explain what a word meant\n * to whoever wrote it.\n */\nexport type Level =\n /**\n * Everything. What a component's own test wants, and the default everywhere.\n *\n * Named rather than left implicit so a subject can be declared strict *back*\n * inside a route that is not — the exception a broad declaration always grows.\n */\n | 'strict'\n /**\n * The page still assembles: `a11y` and `geometry`.\n *\n * `geometry` is the obvious half — boxes appeared, vanished, moved or resized.\n * `a11y` is the half that gets left out and should not be: a control that lost\n * its accessible name is a structural regression that happens to repaint\n * nothing, and a route test that ignored it would be asserting on the shape of\n * the page while blind to the shape a screen reader sees.\n *\n * What it absorbs is `token`, `content` and `texture` — a restyle, a copy edit,\n * anti-aliasing. Each is a real change, each is reported as absorbed, and none\n * of them is what a route-level test is for.\n */\n | 'layout'\n /**\n * What the page *says*: `a11y` and `content`.\n *\n * For a subject whose styling is somebody else's to change — a themed embed, a\n * page inside a design system being actively rebranded — where the assertion\n * worth keeping is that the words and the semantics survived.\n */\n | 'content';\n\nconst LEVELS: Readonly<Record<Level, readonly Band[]>> = {\n strict: BANDS,\n layout: ['a11y', 'geometry'],\n content: ['a11y', 'content'],\n};\n\n/** The bands a level asserts on. Everything else is absorbed and counted. */\nexport function bandsOf(level: Level): readonly Band[] {\n return LEVELS[level];\n}\n\nexport interface SensitivityRule {\n /** Stable name. Appears in the report and in every count this rule produces. */\n readonly id: string;\n\n /**\n * Why this subject is not asserted on in full. Required, as an ignore's is.\n *\n * The difference between a policy somebody chose and one that accreted. A\n * route declared `layout` two years ago by somebody who has left is a blind\n * spot with a plausible-looking config entry in front of it.\n */\n readonly reason: string;\n\n readonly level: Level;\n\n /** Subjects this applies to. `*` matches any run of characters. */\n readonly subjects?: readonly string[];\n\n /**\n * Tags the subject must carry, as its own artifact declared them.\n *\n * FIXME: read by nothing. `asIgnore` copies `subjects` and drops this, so a\n * rule scoped only by tags translates to an unscoped ignore and absorbs across\n * every subject in the run — the widest possible reading of the narrowest\n * scope an operator can write.\n */\n readonly tags?: readonly string[];\n}\n\n/** What one rule absorbed, per band, across a run. */\nexport interface Relaxation {\n readonly rule: string;\n readonly reason: string;\n readonly level: Level;\n\n /** Deltas absorbed because their band is not asserted on here. */\n readonly absorbed: number;\n readonly subjects: readonly string[];\n\n /** Which bands were absorbed, so a level can be tightened with evidence. */\n readonly bands: readonly Band[];\n}\n\nexport interface SensitivityRegister {\n readonly relaxations: readonly Relaxation[];\n\n /**\n * Rules that absorbed nothing in any subject they applied to.\n *\n * The same accounting an ignore's register keeps, and for the same reason: a\n * route declared `layout` that has reported no token change in six months is\n * either a route nothing styles, or a declaration nobody needed. Both are worth\n * a line, and neither is visible without the count.\n */\n readonly dead: readonly string[];\n\n readonly totalAbsorbed: number;\n}\n\nexport interface SensitivityOutcome {\n readonly diffs: readonly SemanticDiff[];\n readonly register: SensitivityRegister;\n}\n\n/**\n * Turn a sensitivity rule into the ignore it is.\n *\n * Not a shortcut — a statement about what these are. A sensitivity names a place\n * and narrows by band, which is exactly the shape `IgnoreRule` already refuses to\n * accept *without* a place, and exactly the shape it accepts with one. Sharing\n * the mechanism means sharing the safety property: the deltas are absorbed by\n * `applyIgnores`, counted per rule, and reported when a rule absorbs nothing.\n *\n * The bands are inverted on the way through, and that inversion is the whole\n * translation. A sensitivity says what it *asserts on*; an ignore says what it\n * *absorbs*. Stating the level positively is what makes a config readable — \"this\n * route asserts on layout\" rather than \"this route ignores token, content and\n * texture\" — and the negative form is what the machinery needs.\n */\nexport function asIgnore(rule: SensitivityRule): IgnoreRule | null {\n const asserted = new Set(bandsOf(rule.level));\n const absorbed = BANDS.filter((band) => !asserted.has(band));\n\n // `strict` asserts on everything, so it absorbs nothing and produces no rule.\n // Returning an ignore with an empty band list would produce a register entry\n // that is permanently dead, which is a line telling an operator to delete the\n // one declaration that is doing nothing wrong.\n if (absorbed.length === 0) return null;\n\n return {\n id: rule.id,\n reason: rule.reason,\n bands: absorbed,\n // The place is the subject, said rather than left to be inferred from a\n // missing selector — see `IgnoreRule.whole`.\n whole: true,\n ...(rule.subjects !== undefined ? { subjects: rule.subjects } : {}),\n };\n}\n\n/**\n * The register as the lines a run prints.\n *\n * Absorption is stated in the *positive* form the operator wrote, because that is\n * the sentence they can check: \"asserts on layout\" is a claim about intent, and\n * \"absorbed 412 token deltas\" is the evidence for or against it.\n */\nexport function summarizeSensitivity(register: SensitivityRegister): readonly string[] {\n if (register.relaxations.length === 0) return [];\n\n const lines = [\n `SENSITIVITY — ${register.totalAbsorbed} difference(s) not asserted on, ` +\n `by ${register.relaxations.length} rule(s)`,\n ];\n\n for (const entry of register.relaxations) {\n if (entry.absorbed === 0) {\n lines.push(\n ` [dead] ${entry.rule} — asserts on ${entry.level} across ` +\n `${entry.subjects.length} subject(s) and absorbed nothing (${entry.reason}); ` +\n 'nothing here needed relaxing',\n );\n continue;\n }\n lines.push(\n ` ${entry.rule} — asserts on ${entry.level}; absorbed ${entry.absorbed} ` +\n `${entry.bands.join('/')} difference(s) in ${entry.subjects.length} subject(s): ` +\n entry.reason,\n );\n }\n\n return lines;\n}\n\n/**\n * Apply declared sensitivities to a run's diffs.\n *\n * A thin fold over {@link applyIgnores}, and thin on purpose: the absorption, the\n * per-rule counting and the dead-rule reporting are one mechanism, and a second\n * implementation of them would be a second set of bugs in the half of the system\n * whose whole job is not to silently hide things.\n *\n * Rules that resolve to `strict` are dropped before the fold rather than passed\n * through, so a project that declares its default explicitly does not collect a\n * permanently-dead register line for saying so.\n */\nexport function applySensitivity(\n diffs: readonly SemanticDiff[],\n rules: readonly SensitivityRule[],\n options: IgnoreOptions = {},\n): SensitivityOutcome {\n const translated = new Map<string, SensitivityRule>();\n const ignores: IgnoreRule[] = [];\n\n for (const rule of rules) {\n const ignore = asIgnore(rule);\n if (ignore === null) continue;\n translated.set(rule.id, rule);\n ignores.push(ignore);\n }\n\n const outcome = applyIgnores(diffs, ignores, options);\n\n const relaxations = [...translated.values()].map((rule) => {\n const absorbed = outcome.register.absorbed.find((entry) => entry.rule === rule.id);\n return {\n rule: rule.id,\n reason: rule.reason,\n level: rule.level,\n absorbed: absorbed?.deltas ?? 0,\n subjects: absorbed?.subjects ?? [],\n bands: absorbed?.bands ?? [],\n };\n });\n\n return {\n diffs: outcome.diffs,\n register: {\n relaxations,\n dead: relaxations.filter((entry) => entry.absorbed === 0).map((entry) => entry.rule),\n totalAbsorbed: relaxations.reduce((sum, entry) => sum + entry.absorbed, 0),\n },\n };\n}\n\n/**\n * What a level does with a set of bands that moved.\n *\n * The arm for the path that holds no second document. `applySensitivity` folds\n * over deltas, which needs both revisions normalized; a `variance run` compares\n * an image against a stored baseline and has only what that baseline carried —\n * per-component hashes, one digest per band (ADR-0027, ADR-0029's split).\n * `bandsBetween` turns those into the same vocabulary, and this decides against\n * it, so the two paths agree by sharing {@link bandsOf} rather than by\n * inspection.\n *\n * **Empty in means nothing absorbed**, not everything. A run whose pixels moved\n * while every component hash held is raster residue — `texture`, which a\n * document cannot carry and which this must therefore never claim to have\n * decided. Absorbing on an empty set would turn the one band a hash comparison\n * is blind to into the one it silences.\n */\nexport function relaxes(\n level: Level,\n moved: readonly Band[],\n): { readonly asserted: readonly Band[]; readonly absorbed: readonly Band[] } {\n const asserting = new Set(bandsOf(level));\n\n return {\n asserted: moved.filter((band) => asserting.has(band)),\n absorbed: moved.filter((band) => !asserting.has(band)),\n };\n}\n\n/**\n * Whether a subject's every moved band falls outside what it is asserted on.\n *\n * The whole subject, because that is the unit a stored baseline can answer for:\n * one image, one verdict. A subject where *some* moved band is asserted on is\n * reported in full — including the bands that would have been absorbed — since\n * a reviewer looking at a nav that moved wants the restyle that came with it,\n * and hiding half a diff is worse than hiding none of it.\n */\nexport function absorbsEntirely(level: Level, moved: readonly Band[]): boolean {\n return moved.length > 0 && relaxes(level, moved).asserted.length === 0;\n}\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type ComponentHash } from '../attribute/component-hash.js';
|
|
2
|
+
import type { Digest } from '../format/hash.js';
|
|
3
|
+
import type { SemanticSnapshot } from '../format/snapshot.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a working session accumulates, for the loop an agent actually runs.
|
|
6
|
+
*
|
|
7
|
+
* `observePair` compares two documents and keeps nothing, which is right for a
|
|
8
|
+
* one-shot ephemeral run and wrong for the setting the mode exists to serve. An
|
|
9
|
+
* agent editing components runs: observe, edit, observe, edit. Each observation
|
|
10
|
+
* on its own can answer *what moved since the previous one*, and the questions
|
|
11
|
+
* that decide what to do next are all about the trajectory:
|
|
12
|
+
*
|
|
13
|
+
* - **What have I changed since I started?** The difference between now and the
|
|
14
|
+
* first step, which is the diff a reviewer will eventually read — never the
|
|
15
|
+
* sum of the per-step differences, because an edit and its revert cancel.
|
|
16
|
+
* - **Did my last edit fix what I broke two edits ago?** Components that moved
|
|
17
|
+
* away from the starting state and have since come back to it.
|
|
18
|
+
* - **Is this the same difference I already saw?** A render hash that has been
|
|
19
|
+
* seen before means the subject has returned to a state this session already
|
|
20
|
+
* observed — going in circles, which is the failure mode of an agent that
|
|
21
|
+
* cannot see its own history.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this is cheap
|
|
24
|
+
*
|
|
25
|
+
* A step is a *snapshot*, not an image. Collecting one is ~7.5 ms against ~65 ms
|
|
26
|
+
* to paint (journal 0007, ADR-0010), and the interesting questions above are all
|
|
27
|
+
* answerable from component hashes and a render hash — text. A paint happens when
|
|
28
|
+
* somebody asks a question about pixels, which in an author loop is rarely and
|
|
29
|
+
* never on every step.
|
|
30
|
+
*
|
|
31
|
+
* ## Why it is here and not in a store
|
|
32
|
+
*
|
|
33
|
+
* A trail is a value and this module is a function over it. Nothing accumulates
|
|
34
|
+
* on disk, nothing is keyed by anything, and the standing constraint that images
|
|
35
|
+
* are never kept in something that accumulates is satisfied by construction:
|
|
36
|
+
* there are no images in it. The holder is whoever runs the loop — an MCP server
|
|
37
|
+
* for a session, a test for its duration — and when they stop holding it, it is
|
|
38
|
+
* gone. That is the ephemeral contract (ADR-0011), not an exception to it.
|
|
39
|
+
*
|
|
40
|
+
* ## What it cannot see
|
|
41
|
+
*
|
|
42
|
+
* Anything the semantic tier cannot. A `RenderDocument`'s `assets` map is
|
|
43
|
+
* optional and no shipped collector fills it, so an image swapped behind an
|
|
44
|
+
* unchanged URL moves no hash here and no step will mention it. That is stated
|
|
45
|
+
* rather than pending: a trail is a cheap tier, and a cheap tier's blind spots
|
|
46
|
+
* are the price of it being cheap. Ask a raster question when the answer matters.
|
|
47
|
+
*/
|
|
48
|
+
export interface TrailStep {
|
|
49
|
+
/**
|
|
50
|
+
* Position in the trail, from `0`. Not a time.
|
|
51
|
+
*
|
|
52
|
+
* `core` has no clock, and an ordinal is the better key anyway: an agent asks
|
|
53
|
+
* "since I started" and "since my last edit", never "since 14:03".
|
|
54
|
+
*/
|
|
55
|
+
readonly at: number;
|
|
56
|
+
/** What the author said they were doing. Carried, never matched on. */
|
|
57
|
+
readonly label?: string;
|
|
58
|
+
/** Identity of the subject's state. Equal hashes are the same render. */
|
|
59
|
+
readonly renderHash: Digest;
|
|
60
|
+
readonly components: readonly ComponentHash[];
|
|
61
|
+
}
|
|
62
|
+
export interface Trail {
|
|
63
|
+
readonly subject: string;
|
|
64
|
+
readonly steps: readonly TrailStep[];
|
|
65
|
+
}
|
|
66
|
+
/** An empty trail for a subject. The first `record` gives it a starting state. */
|
|
67
|
+
export declare function startTrail(subject: string): Trail;
|
|
68
|
+
/**
|
|
69
|
+
* Add this observation to the trail.
|
|
70
|
+
*
|
|
71
|
+
* @throws {Error} when the snapshot is of a different subject. A trail is one
|
|
72
|
+
* subject's history, and appending another's would make every question below
|
|
73
|
+
* answer about a mixture — silently, since the shapes are identical.
|
|
74
|
+
*/
|
|
75
|
+
export declare function record(trail: Trail, snapshot: SemanticSnapshot, label?: string): Trail;
|
|
76
|
+
export interface Progress {
|
|
77
|
+
/** Components differing from where this session started. The reviewable diff. */
|
|
78
|
+
readonly changed: readonly string[];
|
|
79
|
+
/** Components that differ from the previous step. What the last edit did. */
|
|
80
|
+
readonly sinceLast: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Components that had moved away from the starting state and are back at it.
|
|
83
|
+
*
|
|
84
|
+
* The question an agent cannot otherwise ask. Reverting a component is
|
|
85
|
+
* invisible to a per-step comparison — the step *before* the revert saw it
|
|
86
|
+
* change, and the step after sees it change back, and neither says the net
|
|
87
|
+
* effect is nothing. Only the start does.
|
|
88
|
+
*/
|
|
89
|
+
readonly repaired: readonly string[];
|
|
90
|
+
/**
|
|
91
|
+
* The earlier step this state is identical to, when there is one.
|
|
92
|
+
*
|
|
93
|
+
* A render hash that has been seen before means the subject is exactly where it
|
|
94
|
+
* already was, which for an agent means the loop is going in circles. Nothing
|
|
95
|
+
* else in the system can notice, because every other comparison has two
|
|
96
|
+
* elements and this needs the whole history.
|
|
97
|
+
*/
|
|
98
|
+
readonly returnedTo?: number;
|
|
99
|
+
/** `true` when the subject is exactly as this session found it. */
|
|
100
|
+
readonly atStart: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Where the session has got to, in the terms the next decision needs.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately four answers rather than a diff. A diff is what a reviewer reads
|
|
106
|
+
* at the end; what an author needs *during* the loop is whether they are closer
|
|
107
|
+
* than they were, which no single comparison can say.
|
|
108
|
+
*
|
|
109
|
+
* A trail of fewer than two steps has no progress to report — one observation is
|
|
110
|
+
* a starting state, and calling that "nothing changed" would be a claim about an
|
|
111
|
+
* edit nobody has made yet.
|
|
112
|
+
*/
|
|
113
|
+
export declare function progress(trail: Trail): Progress | null;
|
|
114
|
+
/**
|
|
115
|
+
* The trail as the paragraph an agent reads before deciding what to do next.
|
|
116
|
+
*
|
|
117
|
+
* Written for a reader that acts on one sentence, so the ordering is by what
|
|
118
|
+
* changes the next move: whether the work is done, whether it is going in
|
|
119
|
+
* circles, what the last edit did, and only then the accumulated diff.
|
|
120
|
+
*/
|
|
121
|
+
export declare function summarizeTrail(trail: Trail): string;
|
|
122
|
+
//# sourceMappingURL=trail.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { causesBetween, hashComponents, } from '../attribute/component-hash.js';
|
|
2
|
+
/** An empty trail for a subject. The first `record` gives it a starting state. */
|
|
3
|
+
export function startTrail(subject) {
|
|
4
|
+
return { subject, steps: [] };
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Add this observation to the trail.
|
|
8
|
+
*
|
|
9
|
+
* @throws {Error} when the snapshot is of a different subject. A trail is one
|
|
10
|
+
* subject's history, and appending another's would make every question below
|
|
11
|
+
* answer about a mixture — silently, since the shapes are identical.
|
|
12
|
+
*/
|
|
13
|
+
export function record(trail, snapshot, label) {
|
|
14
|
+
if (snapshot.subject.id !== trail.subject) {
|
|
15
|
+
throw new Error(`refusing to record \`${snapshot.subject.id}\` on the trail for \`${trail.subject}\`: ` +
|
|
16
|
+
'a trail is one subject and mixing two would answer every question about neither');
|
|
17
|
+
}
|
|
18
|
+
const step = {
|
|
19
|
+
at: trail.steps.length,
|
|
20
|
+
...(label !== undefined ? { label } : {}),
|
|
21
|
+
renderHash: snapshot.renderHash,
|
|
22
|
+
components: hashesOf(snapshot),
|
|
23
|
+
};
|
|
24
|
+
return { subject: trail.subject, steps: [...trail.steps, step] };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Where the session has got to, in the terms the next decision needs.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately four answers rather than a diff. A diff is what a reviewer reads
|
|
30
|
+
* at the end; what an author needs *during* the loop is whether they are closer
|
|
31
|
+
* than they were, which no single comparison can say.
|
|
32
|
+
*
|
|
33
|
+
* A trail of fewer than two steps has no progress to report — one observation is
|
|
34
|
+
* a starting state, and calling that "nothing changed" would be a claim about an
|
|
35
|
+
* edit nobody has made yet.
|
|
36
|
+
*/
|
|
37
|
+
export function progress(trail) {
|
|
38
|
+
const first = trail.steps[0];
|
|
39
|
+
const last = trail.steps.at(-1);
|
|
40
|
+
if (first === undefined || last === undefined || trail.steps.length < 2)
|
|
41
|
+
return null;
|
|
42
|
+
const previous = trail.steps.at(-2);
|
|
43
|
+
const changed = causesBetween(first.components, last.components);
|
|
44
|
+
const everChanged = new Set(trail.steps.slice(1, -1).flatMap((step) => causesBetween(first.components, step.components)));
|
|
45
|
+
const stillChanged = new Set(changed);
|
|
46
|
+
const seen = trail.steps
|
|
47
|
+
.slice(0, -1)
|
|
48
|
+
.find((step) => step.renderHash === last.renderHash);
|
|
49
|
+
return {
|
|
50
|
+
changed,
|
|
51
|
+
sinceLast: causesBetween(previous.components, last.components),
|
|
52
|
+
repaired: [...everChanged].filter((component) => !stillChanged.has(component)).sort(),
|
|
53
|
+
...(seen !== undefined ? { returnedTo: seen.at } : {}),
|
|
54
|
+
atStart: last.renderHash === first.renderHash,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The trail as the paragraph an agent reads before deciding what to do next.
|
|
59
|
+
*
|
|
60
|
+
* Written for a reader that acts on one sentence, so the ordering is by what
|
|
61
|
+
* changes the next move: whether the work is done, whether it is going in
|
|
62
|
+
* circles, what the last edit did, and only then the accumulated diff.
|
|
63
|
+
*/
|
|
64
|
+
export function summarizeTrail(trail) {
|
|
65
|
+
const state = progress(trail);
|
|
66
|
+
if (state === null) {
|
|
67
|
+
return `${trail.subject}: ${trail.steps.length} step(s) recorded, nothing to compare yet.`;
|
|
68
|
+
}
|
|
69
|
+
const lines = [`${trail.subject}: ${trail.steps.length} steps.`];
|
|
70
|
+
if (state.atStart) {
|
|
71
|
+
lines.push(' The subject is exactly as this session found it. Nothing has net changed.');
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
lines.push(` Changed since step 0: ${state.changed.length === 0 ? 'nothing nameable' : state.changed.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
if (state.returnedTo !== undefined && !state.atStart) {
|
|
77
|
+
lines.push(` This state is identical to step ${state.returnedTo}. The last edits have cancelled ` +
|
|
78
|
+
'out — a different approach is needed, not another attempt at this one.');
|
|
79
|
+
}
|
|
80
|
+
lines.push(` The last edit moved: ${state.sinceLast.length === 0 ? 'nothing' : state.sinceLast.join(', ')}`);
|
|
81
|
+
if (state.repaired.length > 0) {
|
|
82
|
+
lines.push(` Back to where it started: ${state.repaired.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Component hashes for a snapshot, computed here rather than accepted.
|
|
88
|
+
*
|
|
89
|
+
* A parameter would let a caller hand a trail the hashes of one document beside
|
|
90
|
+
* the render hash of another, which makes every answer above quietly wrong and
|
|
91
|
+
* nothing detects it. Computing costs one walk of a tree already in hand.
|
|
92
|
+
*/
|
|
93
|
+
function hashesOf(snapshot) {
|
|
94
|
+
return hashComponents(snapshot);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=trail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trail.js","sourceRoot":"","sources":["../../src/judge/trail.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n causesBetween,\n hashComponents,\n type ComponentHash,\n} from '../attribute/component-hash.js';\nimport type { Digest } from '../format/hash.js';\nimport type { SemanticSnapshot } from '../format/snapshot.js';\n\n/**\n * What a working session accumulates, for the loop an agent actually runs.\n *\n * `observePair` compares two documents and keeps nothing, which is right for a\n * one-shot ephemeral run and wrong for the setting the mode exists to serve. An\n * agent editing components runs: observe, edit, observe, edit. Each observation\n * on its own can answer *what moved since the previous one*, and the questions\n * that decide what to do next are all about the trajectory:\n *\n * - **What have I changed since I started?** The difference between now and the\n * first step, which is the diff a reviewer will eventually read — never the\n * sum of the per-step differences, because an edit and its revert cancel.\n * - **Did my last edit fix what I broke two edits ago?** Components that moved\n * away from the starting state and have since come back to it.\n * - **Is this the same difference I already saw?** A render hash that has been\n * seen before means the subject has returned to a state this session already\n * observed — going in circles, which is the failure mode of an agent that\n * cannot see its own history.\n *\n * ## Why this is cheap\n *\n * A step is a *snapshot*, not an image. Collecting one is ~7.5 ms against ~65 ms\n * to paint (journal 0007, ADR-0010), and the interesting questions above are all\n * answerable from component hashes and a render hash — text. A paint happens when\n * somebody asks a question about pixels, which in an author loop is rarely and\n * never on every step.\n *\n * ## Why it is here and not in a store\n *\n * A trail is a value and this module is a function over it. Nothing accumulates\n * on disk, nothing is keyed by anything, and the standing constraint that images\n * are never kept in something that accumulates is satisfied by construction:\n * there are no images in it. The holder is whoever runs the loop — an MCP server\n * for a session, a test for its duration — and when they stop holding it, it is\n * gone. That is the ephemeral contract (ADR-0011), not an exception to it.\n *\n * ## What it cannot see\n *\n * Anything the semantic tier cannot. A `RenderDocument`'s `assets` map is\n * optional and no shipped collector fills it, so an image swapped behind an\n * unchanged URL moves no hash here and no step will mention it. That is stated\n * rather than pending: a trail is a cheap tier, and a cheap tier's blind spots\n * are the price of it being cheap. Ask a raster question when the answer matters.\n */\n\nexport interface TrailStep {\n /**\n * Position in the trail, from `0`. Not a time.\n *\n * `core` has no clock, and an ordinal is the better key anyway: an agent asks\n * \"since I started\" and \"since my last edit\", never \"since 14:03\".\n */\n readonly at: number;\n\n /** What the author said they were doing. Carried, never matched on. */\n readonly label?: string;\n\n /** Identity of the subject's state. Equal hashes are the same render. */\n readonly renderHash: Digest;\n\n readonly components: readonly ComponentHash[];\n}\n\nexport interface Trail {\n readonly subject: string;\n readonly steps: readonly TrailStep[];\n}\n\n/** An empty trail for a subject. The first `record` gives it a starting state. */\nexport function startTrail(subject: string): Trail {\n return { subject, steps: [] };\n}\n\n/**\n * Add this observation to the trail.\n *\n * @throws {Error} when the snapshot is of a different subject. A trail is one\n * subject's history, and appending another's would make every question below\n * answer about a mixture — silently, since the shapes are identical.\n */\nexport function record(trail: Trail, snapshot: SemanticSnapshot, label?: string): Trail {\n if (snapshot.subject.id !== trail.subject) {\n throw new Error(\n `refusing to record \\`${snapshot.subject.id}\\` on the trail for \\`${trail.subject}\\`: ` +\n 'a trail is one subject and mixing two would answer every question about neither',\n );\n }\n\n const step: TrailStep = {\n at: trail.steps.length,\n ...(label !== undefined ? { label } : {}),\n renderHash: snapshot.renderHash,\n components: hashesOf(snapshot),\n };\n\n return { subject: trail.subject, steps: [...trail.steps, step] };\n}\n\nexport interface Progress {\n /** Components differing from where this session started. The reviewable diff. */\n readonly changed: readonly string[];\n\n /** Components that differ from the previous step. What the last edit did. */\n readonly sinceLast: readonly string[];\n\n /**\n * Components that had moved away from the starting state and are back at it.\n *\n * The question an agent cannot otherwise ask. Reverting a component is\n * invisible to a per-step comparison — the step *before* the revert saw it\n * change, and the step after sees it change back, and neither says the net\n * effect is nothing. Only the start does.\n */\n readonly repaired: readonly string[];\n\n /**\n * The earlier step this state is identical to, when there is one.\n *\n * A render hash that has been seen before means the subject is exactly where it\n * already was, which for an agent means the loop is going in circles. Nothing\n * else in the system can notice, because every other comparison has two\n * elements and this needs the whole history.\n */\n readonly returnedTo?: number;\n\n /** `true` when the subject is exactly as this session found it. */\n readonly atStart: boolean;\n}\n\n/**\n * Where the session has got to, in the terms the next decision needs.\n *\n * Deliberately four answers rather than a diff. A diff is what a reviewer reads\n * at the end; what an author needs *during* the loop is whether they are closer\n * than they were, which no single comparison can say.\n *\n * A trail of fewer than two steps has no progress to report — one observation is\n * a starting state, and calling that \"nothing changed\" would be a claim about an\n * edit nobody has made yet.\n */\nexport function progress(trail: Trail): Progress | null {\n const first = trail.steps[0];\n const last = trail.steps.at(-1);\n if (first === undefined || last === undefined || trail.steps.length < 2) return null;\n\n const previous = trail.steps.at(-2)!;\n\n const changed = causesBetween(first.components, last.components);\n const everChanged = new Set(\n trail.steps.slice(1, -1).flatMap((step) => causesBetween(first.components, step.components)),\n );\n const stillChanged = new Set(changed);\n\n const seen = trail.steps\n .slice(0, -1)\n .find((step) => step.renderHash === last.renderHash);\n\n return {\n changed,\n sinceLast: causesBetween(previous.components, last.components),\n repaired: [...everChanged].filter((component) => !stillChanged.has(component)).sort(),\n ...(seen !== undefined ? { returnedTo: seen.at } : {}),\n atStart: last.renderHash === first.renderHash,\n };\n}\n\n/**\n * The trail as the paragraph an agent reads before deciding what to do next.\n *\n * Written for a reader that acts on one sentence, so the ordering is by what\n * changes the next move: whether the work is done, whether it is going in\n * circles, what the last edit did, and only then the accumulated diff.\n */\nexport function summarizeTrail(trail: Trail): string {\n const state = progress(trail);\n if (state === null) {\n return `${trail.subject}: ${trail.steps.length} step(s) recorded, nothing to compare yet.`;\n }\n\n const lines = [`${trail.subject}: ${trail.steps.length} steps.`];\n\n if (state.atStart) {\n lines.push(' The subject is exactly as this session found it. Nothing has net changed.');\n } else {\n lines.push(\n ` Changed since step 0: ${state.changed.length === 0 ? 'nothing nameable' : state.changed.join(', ')}`,\n );\n }\n\n if (state.returnedTo !== undefined && !state.atStart) {\n lines.push(\n ` This state is identical to step ${state.returnedTo}. The last edits have cancelled ` +\n 'out — a different approach is needed, not another attempt at this one.',\n );\n }\n\n lines.push(\n ` The last edit moved: ${state.sinceLast.length === 0 ? 'nothing' : state.sinceLast.join(', ')}`,\n );\n\n if (state.repaired.length > 0) {\n lines.push(` Back to where it started: ${state.repaired.join(', ')}`);\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Component hashes for a snapshot, computed here rather than accepted.\n *\n * A parameter would let a caller hand a trail the hashes of one document beside\n * the render hash of another, which makes every answer above quietly wrong and\n * nothing detects it. Computing costs one walk of a tree already in hand.\n */\nfunction hashesOf(snapshot: SemanticSnapshot): readonly ComponentHash[] {\n return hashComponents(snapshot);\n}\n"]}
|