@lovrozagar/oat 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/LICENSE +21 -0
- package/README.md +401 -0
- package/bin/oat.js +2 -0
- package/dist/cli.js +386 -0
- package/dist/config/define-config.d.ts +165 -0
- package/dist/config/define-config.d.ts.map +1 -0
- package/dist/config/define-config.js +24 -0
- package/dist/config/define-config.js.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +61 -0
- package/dist/config/load.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/reference/defects.d.ts +254 -0
- package/dist/reference/defects.d.ts.map +1 -0
- package/dist/reference/defects.js +265 -0
- package/dist/reference/defects.js.map +1 -0
- package/dist/reference/dialect.d.ts +104 -0
- package/dist/reference/dialect.d.ts.map +1 -0
- package/dist/reference/dialect.js +174 -0
- package/dist/reference/dialect.js.map +1 -0
- package/dist/reference/http.d.ts +79 -0
- package/dist/reference/http.d.ts.map +1 -0
- package/dist/reference/http.js +889 -0
- package/dist/reference/http.js.map +1 -0
- package/dist/reference/model.d.ts +56 -0
- package/dist/reference/model.d.ts.map +1 -0
- package/dist/reference/model.js +156 -0
- package/dist/reference/model.js.map +1 -0
- package/dist/reference/query.d.ts +45 -0
- package/dist/reference/query.d.ts.map +1 -0
- package/dist/reference/query.js +353 -0
- package/dist/reference/query.js.map +1 -0
- package/dist/reference/spec.d.ts +13 -0
- package/dist/reference/spec.d.ts.map +1 -0
- package/dist/reference/spec.js +547 -0
- package/dist/reference/spec.js.map +1 -0
- package/dist/reference/store-api.d.ts +59 -0
- package/dist/reference/store-api.d.ts.map +1 -0
- package/dist/reference/store-api.js +76 -0
- package/dist/reference/store-api.js.map +1 -0
- package/dist/reference/stores/memory.d.ts +27 -0
- package/dist/reference/stores/memory.d.ts.map +1 -0
- package/dist/reference/stores/memory.js +97 -0
- package/dist/reference/stores/memory.js.map +1 -0
- package/dist/reference/stores/postgres.d.ts +61 -0
- package/dist/reference/stores/postgres.d.ts.map +1 -0
- package/dist/reference/stores/postgres.js +406 -0
- package/dist/reference/stores/postgres.js.map +1 -0
- package/dist/reference/stores/sqlite-driver.d.ts +54 -0
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
- package/dist/reference/stores/sqlite-driver.js +99 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +68 -0
- package/dist/reference/stores/sqlite.d.ts.map +1 -0
- package/dist/reference/stores/sqlite.js +464 -0
- package/dist/reference/stores/sqlite.js.map +1 -0
- package/dist/report/console.d.ts +33 -0
- package/dist/report/console.d.ts.map +1 -0
- package/dist/report/console.js +237 -0
- package/dist/report/console.js.map +1 -0
- package/dist/report/matrix.d.ts +131 -0
- package/dist/report/matrix.d.ts.map +1 -0
- package/dist/report/matrix.js +865 -0
- package/dist/report/matrix.js.map +1 -0
- package/dist/report/render.d.ts +66 -0
- package/dist/report/render.d.ts.map +1 -0
- package/dist/report/render.js +423 -0
- package/dist/report/render.js.map +1 -0
- package/dist/runtime/async.d.ts +34 -0
- package/dist/runtime/async.d.ts.map +1 -0
- package/dist/runtime/async.js +130 -0
- package/dist/runtime/async.js.map +1 -0
- package/dist/runtime/auth.d.ts +46 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +236 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/checks.d.ts +89 -0
- package/dist/runtime/checks.d.ts.map +1 -0
- package/dist/runtime/checks.js +3400 -0
- package/dist/runtime/checks.js.map +1 -0
- package/dist/runtime/client.d.ts +65 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +122 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/finding.d.ts +50 -0
- package/dist/runtime/finding.d.ts.map +1 -0
- package/dist/runtime/finding.js +50 -0
- package/dist/runtime/finding.js.map +1 -0
- package/dist/runtime/fixture.d.ts +20 -0
- package/dist/runtime/fixture.d.ts.map +1 -0
- package/dist/runtime/fixture.js +175 -0
- package/dist/runtime/fixture.js.map +1 -0
- package/dist/runtime/progress.d.ts +41 -0
- package/dist/runtime/progress.d.ts.map +1 -0
- package/dist/runtime/progress.js +186 -0
- package/dist/runtime/progress.js.map +1 -0
- package/dist/runtime/run.d.ts +69 -0
- package/dist/runtime/run.d.ts.map +1 -0
- package/dist/runtime/run.js +575 -0
- package/dist/runtime/run.js.map +1 -0
- package/dist/runtime/teardown.d.ts +38 -0
- package/dist/runtime/teardown.d.ts.map +1 -0
- package/dist/runtime/teardown.js +129 -0
- package/dist/runtime/teardown.js.map +1 -0
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.d.ts.map +1 -0
- package/dist/runtime/validate.js +111 -0
- package/dist/runtime/validate.js.map +1 -0
- package/dist/runtime/world.d.ts +49 -0
- package/dist/runtime/world.d.ts.map +1 -0
- package/dist/runtime/world.js +119 -0
- package/dist/runtime/world.js.map +1 -0
- package/dist/spec/collection.d.ts +31 -0
- package/dist/spec/collection.d.ts.map +1 -0
- package/dist/spec/collection.js +121 -0
- package/dist/spec/collection.js.map +1 -0
- package/dist/spec/conventions.d.ts +103 -0
- package/dist/spec/conventions.d.ts.map +1 -0
- package/dist/spec/conventions.js +275 -0
- package/dist/spec/conventions.js.map +1 -0
- package/dist/spec/extensions.d.ts +74 -0
- package/dist/spec/extensions.d.ts.map +1 -0
- package/dist/spec/extensions.js +335 -0
- package/dist/spec/extensions.js.map +1 -0
- package/dist/spec/graph.d.ts +89 -0
- package/dist/spec/graph.d.ts.map +1 -0
- package/dist/spec/graph.js +318 -0
- package/dist/spec/graph.js.map +1 -0
- package/dist/spec/load.d.ts +36 -0
- package/dist/spec/load.d.ts.map +1 -0
- package/dist/spec/load.js +166 -0
- package/dist/spec/load.js.map +1 -0
- package/dist/spec/types.d.ts +57 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +24 -0
- package/dist/spec/types.js.map +1 -0
- package/labs/README.md +130 -0
- package/labs/annotated-openapi.yaml +311 -0
- package/labs/anyrow.config.ts +107 -0
- package/labs/demo.mjs +124 -0
- package/labs/local.config.ts +41 -0
- package/labs/minimal.config.ts +32 -0
- package/package.json +80 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poster + machine graph of how oat crossed the entities in a run.
|
|
3
|
+
*
|
|
4
|
+
* One entity is a loom: warp windows on one cohort, weft cells woven only if the warp holds.
|
|
5
|
+
* Several entities add a second graph on top — `x-invalidate` edges from a writer to another
|
|
6
|
+
* entity's read surface. That is the claim `invalidation.declared-route-changes` actually tests.
|
|
7
|
+
*/
|
|
8
|
+
import { CHECKS } from "../runtime/checks.js";
|
|
9
|
+
const NODE_ORDER = [
|
|
10
|
+
{ hint: "create then list", id: "list.read-after-write", label: "list" },
|
|
11
|
+
{ hint: "item GET agrees", id: "consistency.projections-agree", label: "item" },
|
|
12
|
+
{ hint: "id.eq selects one", id: "filter.equality-selects-exactly-one", label: "filter" },
|
|
13
|
+
{ hint: "order rearranges", id: "sort.order-is-applied", label: "sort" },
|
|
14
|
+
{ hint: "sparse fieldset", id: "select.projection-honoured", label: "select" },
|
|
15
|
+
{ hint: "q narrows", id: "search.q-narrows-result", label: "search" },
|
|
16
|
+
{ hint: "walk covers set", id: "pagination.page-walk-covers-set", label: "page" },
|
|
17
|
+
{ hint: "other tenant 404", id: "tenant.item-not-readable-cross-tenant", label: "tenant" },
|
|
18
|
+
{ hint: "membership unchanged", id: "query.axes-compose", label: "filter + sort" },
|
|
19
|
+
{ hint: "same rows, fewer fields", id: "query.filter-and-select-compose", label: "filter + select" },
|
|
20
|
+
{ hint: "both predicates hold", id: "query.search-and-filter-compose", label: "filter + search" },
|
|
21
|
+
{ hint: "filter before page", id: "query.filter-selects-from-whole-set", label: "filter / page" },
|
|
22
|
+
{ hint: "three axes at once", id: "query.filter-sort-select-compose", label: "filter + sort + select" },
|
|
23
|
+
{ hint: "three axes at once", id: "query.filter-search-sort-compose", label: "filter + search + sort" },
|
|
24
|
+
{ hint: "three axes at once", id: "query.filter-search-select-compose", label: "filter + search + select" },
|
|
25
|
+
];
|
|
26
|
+
const USES = [
|
|
27
|
+
{ from: "filter.equality-selects-exactly-one", to: "query.axes-compose" },
|
|
28
|
+
{ from: "sort.order-is-applied", to: "query.axes-compose" },
|
|
29
|
+
{ from: "filter.equality-selects-exactly-one", to: "query.filter-and-select-compose" },
|
|
30
|
+
{ from: "select.projection-honoured", to: "query.filter-and-select-compose" },
|
|
31
|
+
{ from: "filter.equality-selects-exactly-one", to: "query.search-and-filter-compose" },
|
|
32
|
+
{ from: "search.q-narrows-result", to: "query.search-and-filter-compose" },
|
|
33
|
+
{ from: "filter.equality-selects-exactly-one", to: "query.filter-selects-from-whole-set" },
|
|
34
|
+
{ from: "pagination.page-walk-covers-set", to: "query.filter-selects-from-whole-set" },
|
|
35
|
+
{ from: "query.axes-compose", to: "query.filter-sort-select-compose" },
|
|
36
|
+
{ from: "query.filter-and-select-compose", to: "query.filter-sort-select-compose" },
|
|
37
|
+
{ from: "query.axes-compose", to: "query.filter-search-sort-compose" },
|
|
38
|
+
{ from: "query.search-and-filter-compose", to: "query.filter-search-sort-compose" },
|
|
39
|
+
{ from: "query.filter-and-select-compose", to: "query.filter-search-select-compose" },
|
|
40
|
+
{ from: "query.search-and-filter-compose", to: "query.filter-search-select-compose" },
|
|
41
|
+
];
|
|
42
|
+
const GROUP_LAYER = {
|
|
43
|
+
composition: "weft",
|
|
44
|
+
filter: "warp",
|
|
45
|
+
"page / count": "warp",
|
|
46
|
+
schema: "schema",
|
|
47
|
+
search: "warp",
|
|
48
|
+
select: "warp",
|
|
49
|
+
sort: "warp",
|
|
50
|
+
"spec promise": "spec",
|
|
51
|
+
"who may see it": "auth",
|
|
52
|
+
"write / surface": "surface",
|
|
53
|
+
};
|
|
54
|
+
const STRIPS = [
|
|
55
|
+
{
|
|
56
|
+
ids: [
|
|
57
|
+
"filter.unknown-field-rejected",
|
|
58
|
+
"filter.equality-selects-exactly-one",
|
|
59
|
+
"filter.zero-match-returns-none",
|
|
60
|
+
"filter.negation-partitions-the-set",
|
|
61
|
+
"filter.and-composes-as-intersection",
|
|
62
|
+
"filter.or-composes-as-union",
|
|
63
|
+
"filter.like-metacharacters-escaped",
|
|
64
|
+
"filter.numeric-comparison-is-numeric",
|
|
65
|
+
"error.malformed-filter-not-5xx",
|
|
66
|
+
],
|
|
67
|
+
title: "filter",
|
|
68
|
+
},
|
|
69
|
+
{ ids: ["sort.order-is-applied", "sort.reverse-symmetry"], title: "sort" },
|
|
70
|
+
{ ids: ["select.projection-honoured"], title: "select" },
|
|
71
|
+
{ ids: ["search.q-narrows-result"], title: "search" },
|
|
72
|
+
{
|
|
73
|
+
ids: [
|
|
74
|
+
"pagination.page-walk-covers-set",
|
|
75
|
+
"pagination.cursor-agrees-with-page",
|
|
76
|
+
"pagination.limit-bounds-page-size",
|
|
77
|
+
"pagination.limit-respects-documented-max",
|
|
78
|
+
"pagination.has-more-is-accurate",
|
|
79
|
+
"count.consistent-with-returned-page",
|
|
80
|
+
"count.matches-filtered-set",
|
|
81
|
+
],
|
|
82
|
+
title: "page / count",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
ids: [
|
|
86
|
+
"query.axes-compose",
|
|
87
|
+
"query.filter-and-select-compose",
|
|
88
|
+
"query.search-and-filter-compose",
|
|
89
|
+
"query.filter-selects-from-whole-set",
|
|
90
|
+
"query.filter-sort-select-compose",
|
|
91
|
+
"query.filter-search-sort-compose",
|
|
92
|
+
"query.filter-search-select-compose",
|
|
93
|
+
],
|
|
94
|
+
title: "composition",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
ids: [
|
|
98
|
+
"spec.declared-filterable-is-filterable",
|
|
99
|
+
"spec.declared-sortable-is-sortable",
|
|
100
|
+
"spec.declared-selectable-is-selectable",
|
|
101
|
+
],
|
|
102
|
+
title: "spec promise",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
ids: [
|
|
106
|
+
"tenant.item-not-readable-cross-tenant",
|
|
107
|
+
"tenant.denial-does-not-reveal-existence",
|
|
108
|
+
"tenant.filter-does-not-bypass-scope",
|
|
109
|
+
"auth.rank-is-monotonic",
|
|
110
|
+
"auth.invite-grants-then-revokes",
|
|
111
|
+
],
|
|
112
|
+
title: "who may see it",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
ids: [
|
|
116
|
+
"list.read-after-write",
|
|
117
|
+
"create.persists-submitted-fields",
|
|
118
|
+
"create.status-matches-document",
|
|
119
|
+
"consistency.projections-agree",
|
|
120
|
+
"softdelete.absent-from-default-list",
|
|
121
|
+
"invalidation.declared-route-changes",
|
|
122
|
+
"patch.minimality",
|
|
123
|
+
"patch.immutable-field-rejected",
|
|
124
|
+
"idempotency.replay-does-not-duplicate",
|
|
125
|
+
"delete.absent-record-returns-404",
|
|
126
|
+
"concurrency.no-lost-update",
|
|
127
|
+
"effects.declared-effect-occurs",
|
|
128
|
+
"async.reaches-terminal-state",
|
|
129
|
+
"async.receipt-identifies-the-job",
|
|
130
|
+
],
|
|
131
|
+
title: "write / surface",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
ids: [
|
|
135
|
+
"validation.enum-enforced",
|
|
136
|
+
"validation.max-length-enforced",
|
|
137
|
+
"validation.required-enforced",
|
|
138
|
+
"validation.content-type-enforced",
|
|
139
|
+
"schema.success-response-matches-document",
|
|
140
|
+
"schema.error-response-matches-document",
|
|
141
|
+
],
|
|
142
|
+
title: "schema",
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
const ALL_CHECK_IDS = [...new Set(STRIPS.flatMap((s) => s.ids))];
|
|
146
|
+
const GROUP_OF = new Map(STRIPS.flatMap((s) => s.ids.map((id) => [id, s.title])));
|
|
147
|
+
const NEEDS = new Map(CHECKS.map((check) => [check.id, check.needs]));
|
|
148
|
+
const CHECK_EDGES = [
|
|
149
|
+
...CHECKS.flatMap((check) => (check.dependsOn ?? []).map((dep) => ({ from: dep, kind: "dependsOn", to: check.id }))),
|
|
150
|
+
...USES.map((use) => ({ ...use, kind: "uses" })),
|
|
151
|
+
];
|
|
152
|
+
export function matrixViewFromReport(input) {
|
|
153
|
+
const entity = input.entitiesTested[0] ?? "entity";
|
|
154
|
+
return matrixViewFromParts({
|
|
155
|
+
baseUrl: input.baseUrl,
|
|
156
|
+
checksRun: input.checksRun,
|
|
157
|
+
checksSkipped: input.checksSkipped ?? [],
|
|
158
|
+
checksSuppressed: input.checksSuppressed ?? [],
|
|
159
|
+
entity,
|
|
160
|
+
findings: input.findings,
|
|
161
|
+
generatedAt: input.startedAt.toISOString(),
|
|
162
|
+
readSurface: input.model.entities.get(entity)?.readSurface ?? [],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function matrixViewFromParts(parts) {
|
|
166
|
+
const slice = entitySlice(parts.entity, parts, parts.readSurface, null);
|
|
167
|
+
const nodes = NODE_ORDER.map((spec) => {
|
|
168
|
+
const status = slice.nodes.find((n) => n.id === spec.id)?.status ?? "skipped";
|
|
169
|
+
const node = { hint: spec.hint, id: spec.id, label: spec.label, status };
|
|
170
|
+
const cause = slice.nodes.find((n) => n.id === spec.id)?.because;
|
|
171
|
+
if (status === "blocked" && cause !== undefined)
|
|
172
|
+
node.because = cause;
|
|
173
|
+
if (status === "failed")
|
|
174
|
+
node.hint = "disagreed";
|
|
175
|
+
return node;
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
baseUrl: parts.baseUrl,
|
|
179
|
+
blocked: slice.counts.blocked,
|
|
180
|
+
entity: parts.entity,
|
|
181
|
+
failed: slice.counts.failed,
|
|
182
|
+
generatedAt: parts.generatedAt,
|
|
183
|
+
held: slice.counts.held,
|
|
184
|
+
nodes,
|
|
185
|
+
readSurface: parts.readSurface,
|
|
186
|
+
skipped: slice.counts.skipped,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function renderMatrixFromParts(parts) {
|
|
190
|
+
return renderPoster(buildMatrixGraph(parts));
|
|
191
|
+
}
|
|
192
|
+
export function renderMatrixHtml(input) {
|
|
193
|
+
return renderPoster(buildMatrixGraphFromReport(input));
|
|
194
|
+
}
|
|
195
|
+
export function renderMatrixGraph(input) {
|
|
196
|
+
return `${JSON.stringify(compactMatrixGraph(buildMatrixGraphFromReport(input)), null, 2)}\n`;
|
|
197
|
+
}
|
|
198
|
+
export function renderMatrixGraphFromParts(parts) {
|
|
199
|
+
return `${JSON.stringify(compactMatrixGraph(buildMatrixGraph(parts)), null, 2)}\n`;
|
|
200
|
+
}
|
|
201
|
+
export function buildMatrixGraphFromReport(input) {
|
|
202
|
+
const names = input.entitiesTested.length > 0 ? input.entitiesTested : ["entity"];
|
|
203
|
+
const partsFor = (name) => ({
|
|
204
|
+
baseUrl: input.baseUrl,
|
|
205
|
+
checksRun: input.checksRun,
|
|
206
|
+
checksSkipped: input.checksSkipped ?? [],
|
|
207
|
+
checksSuppressed: input.checksSuppressed ?? [],
|
|
208
|
+
entity: name,
|
|
209
|
+
findings: input.findings,
|
|
210
|
+
generatedAt: input.startedAt.toISOString(),
|
|
211
|
+
readSurface: input.model.entities.get(name)?.readSurface ?? [],
|
|
212
|
+
});
|
|
213
|
+
const entities = names.map((name) => {
|
|
214
|
+
const modelEntity = input.model.entities.get(name);
|
|
215
|
+
return entitySlice(name, partsFor(name), modelEntity?.readSurface ?? [], modelEntity?.identity ?? null);
|
|
216
|
+
});
|
|
217
|
+
return assembleGraph(input.baseUrl, input.startedAt.toISOString(), entities, invalidateLinks(input.model));
|
|
218
|
+
}
|
|
219
|
+
export function buildMatrixGraph(parts) {
|
|
220
|
+
const slice = entitySlice(parts.entity, parts, parts.readSurface, null);
|
|
221
|
+
return assembleGraph(parts.baseUrl, parts.generatedAt, [slice], []);
|
|
222
|
+
}
|
|
223
|
+
function entitySlice(name, parts, readSurface, identity) {
|
|
224
|
+
const nodes = ALL_CHECK_IDS.map((id) => {
|
|
225
|
+
const group = GROUP_OF.get(id) ?? "other";
|
|
226
|
+
const node = {
|
|
227
|
+
group,
|
|
228
|
+
id,
|
|
229
|
+
layer: GROUP_LAYER[group] ?? "surface",
|
|
230
|
+
status: statusFor(name, id, parts),
|
|
231
|
+
};
|
|
232
|
+
const need = NEEDS.get(id);
|
|
233
|
+
if (need !== undefined)
|
|
234
|
+
node.needs = need;
|
|
235
|
+
const suppressed = parts.checksSuppressed.find((s) => s.check === id && matchesEntity(s.entity, name));
|
|
236
|
+
if (suppressed !== undefined)
|
|
237
|
+
node.because = suppressed.because;
|
|
238
|
+
const finding = parts.findings.find((f) => f.check === id && matchesEntity(f.entity, name));
|
|
239
|
+
if (finding !== undefined) {
|
|
240
|
+
node.summary = finding.summary;
|
|
241
|
+
node.verdict = finding.verdict;
|
|
242
|
+
}
|
|
243
|
+
return node;
|
|
244
|
+
});
|
|
245
|
+
const counts = { blocked: 0, failed: 0, held: 0, skipped: 0 };
|
|
246
|
+
for (const node of nodes)
|
|
247
|
+
counts[node.status] += 1;
|
|
248
|
+
const failedSet = new Set(nodes.filter((n) => n.status === "failed").map((n) => n.id));
|
|
249
|
+
const roots = [...failedSet].filter((id) => !CHECK_EDGES.some((e) => e.kind === "dependsOn" && e.to === id && failedSet.has(e.from)));
|
|
250
|
+
return { counts, identity, name, nodes, readSurface, roots };
|
|
251
|
+
}
|
|
252
|
+
function statusFor(entity, id, parts) {
|
|
253
|
+
const finding = parts.findings.find((f) => f.check === id
|
|
254
|
+
&& matchesEntity(f.entity, entity)
|
|
255
|
+
&& f.verdict !== "COVERAGE_GAP"
|
|
256
|
+
&& f.verdict !== "BLOCKED");
|
|
257
|
+
if (finding !== undefined)
|
|
258
|
+
return "failed";
|
|
259
|
+
if (parts.checksSuppressed.some((s) => s.check === id && matchesEntity(s.entity, entity)))
|
|
260
|
+
return "blocked";
|
|
261
|
+
if (parts.checksSkipped.some((s) => s.check === id && matchesEntity(s.entity, entity)))
|
|
262
|
+
return "skipped";
|
|
263
|
+
if (parts.checksRun.includes(id))
|
|
264
|
+
return "held";
|
|
265
|
+
return "skipped";
|
|
266
|
+
}
|
|
267
|
+
function matchesEntity(value, entity) {
|
|
268
|
+
return value === undefined || value === entity;
|
|
269
|
+
}
|
|
270
|
+
function invalidateLinks(model) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const op of model.operations) {
|
|
273
|
+
if (op.entity === null || op.invalidates.length === 0)
|
|
274
|
+
continue;
|
|
275
|
+
for (const route of op.invalidates) {
|
|
276
|
+
const target = model.byRoute.get(route);
|
|
277
|
+
const toEntity = target?.entity ?? owningEntityFromRoute(route, model) ?? op.entity;
|
|
278
|
+
out.push({
|
|
279
|
+
cross: toEntity !== op.entity,
|
|
280
|
+
fromEntity: op.entity,
|
|
281
|
+
fromOp: op.operationId,
|
|
282
|
+
toEntity,
|
|
283
|
+
toRoute: route,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
function owningEntityFromRoute(route, model) {
|
|
290
|
+
for (const entity of model.entities.values()) {
|
|
291
|
+
if (entity.readSurface.includes(route))
|
|
292
|
+
return entity.name;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function assembleGraph(baseUrl, generatedAt, entities, invalidate) {
|
|
297
|
+
const counts = { blocked: 0, failed: 0, held: 0, skipped: 0 };
|
|
298
|
+
for (const entity of entities) {
|
|
299
|
+
counts.failed += entity.counts.failed;
|
|
300
|
+
counts.blocked += entity.counts.blocked;
|
|
301
|
+
counts.held += entity.counts.held;
|
|
302
|
+
counts.skipped += entity.counts.skipped;
|
|
303
|
+
}
|
|
304
|
+
const cross = invalidate.filter((link) => link.cross);
|
|
305
|
+
const names = entities.map((e) => e.name);
|
|
306
|
+
const thesis = thesisFor(entities, cross);
|
|
307
|
+
const rootList = entities.flatMap((e) => e.roots.map((id) => `${e.name}:${id}`));
|
|
308
|
+
const who = names.length > 12 ? `${names.length} entities` : names.join(", ");
|
|
309
|
+
const inbound = {};
|
|
310
|
+
for (const link of cross)
|
|
311
|
+
inbound[link.toEntity] = (inbound[link.toEntity] ?? 0) + 1;
|
|
312
|
+
const summary = `${who}: ${counts.failed} failed, ${counts.blocked} blocked, ${counts.held} held, ${counts.skipped} skipped.`
|
|
313
|
+
+ (cross.length > 8
|
|
314
|
+
? ` ${unique(cross.map((l) => l.toEntity)).length} parents receive ${cross.length} cross-entity claims.`
|
|
315
|
+
: cross.length > 0
|
|
316
|
+
? ` Cross-entity invalidate: ${unique(cross.map((l) => `${l.fromEntity} → ${l.toEntity}`)).join(", ")}.`
|
|
317
|
+
: " No cross-entity invalidate.")
|
|
318
|
+
+ (rootList.length > 0 && rootList.length <= 8 ? ` Roots: ${rootList.join(", ")}.` : "");
|
|
319
|
+
return {
|
|
320
|
+
kind: "oat.matrix",
|
|
321
|
+
version: 2,
|
|
322
|
+
read: {
|
|
323
|
+
edges: {
|
|
324
|
+
dependsOn: "if `from` failed, `to` is blocked. Do not treat blocked as a second defect.",
|
|
325
|
+
invalidate: "a write on `fromEntity` is declared to change `toRoute` on `toEntity`. `cross` is the case the invalidation check probes.",
|
|
326
|
+
uses: "weft composition cell woven from this warp window",
|
|
327
|
+
},
|
|
328
|
+
purpose: "How oat crossed the entities in this run. Each entity is an independent loom. "
|
|
329
|
+
+ "`invalidate` edges are x-invalidate claims. failed = two windows disagreed. "
|
|
330
|
+
+ "blocked = a dependsOn already failed — not a second defect.",
|
|
331
|
+
status: {
|
|
332
|
+
blocked: "not evaluated; a dependsOn check already failed",
|
|
333
|
+
failed: "two projections of the same fact disagreed — a finding",
|
|
334
|
+
held: "tested, and the projections agreed",
|
|
335
|
+
skipped: "this document has no window for the check",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
summary,
|
|
339
|
+
thesis,
|
|
340
|
+
index: {
|
|
341
|
+
crossClaims: cross.length,
|
|
342
|
+
entityCount: entities.length,
|
|
343
|
+
failed: entities.filter((e) => e.counts.failed > 0).map((e) => e.name),
|
|
344
|
+
inbound,
|
|
345
|
+
parents: unique(cross.map((l) => l.toEntity)),
|
|
346
|
+
},
|
|
347
|
+
baseUrl,
|
|
348
|
+
generatedAt,
|
|
349
|
+
counts,
|
|
350
|
+
entities,
|
|
351
|
+
invalidate,
|
|
352
|
+
edges: CHECK_EDGES,
|
|
353
|
+
mermaid: mermaidFromGraph(entities, invalidate),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** Drop per-check nodes for clean entities so a 2k-entity graph stays loadable. */
|
|
357
|
+
export function compactMatrixGraph(graph) {
|
|
358
|
+
if (graph.entities.length <= 80)
|
|
359
|
+
return graph;
|
|
360
|
+
const cross = graph.invalidate.filter((l) => l.cross);
|
|
361
|
+
return {
|
|
362
|
+
...graph,
|
|
363
|
+
edges: graph.edges.filter((e) => e.kind === "uses"),
|
|
364
|
+
entities: graph.entities.map((entity) => entity.counts.failed === 0 && entity.counts.blocked === 0 ? { ...entity, nodes: [] } : entity),
|
|
365
|
+
invalidate: unique(cross.map((l) => `${l.fromEntity}\0${l.toEntity}`)).map((key) => {
|
|
366
|
+
const [fromEntity, toEntity] = key.split("\0");
|
|
367
|
+
return {
|
|
368
|
+
cross: true,
|
|
369
|
+
fromEntity,
|
|
370
|
+
fromOp: `${fromEntity}.create`,
|
|
371
|
+
toEntity,
|
|
372
|
+
toRoute: `GET …/${toEntity}`,
|
|
373
|
+
};
|
|
374
|
+
}),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function thesisFor(entities, cross) {
|
|
378
|
+
const failed = entities.filter((e) => e.counts.failed > 0);
|
|
379
|
+
if (failed.length > 0) {
|
|
380
|
+
const first = failed[0];
|
|
381
|
+
if (first === undefined)
|
|
382
|
+
return "";
|
|
383
|
+
return `${first.name} had a window that disagreed. Weft cells that use it stood down — they are not extra defects.`;
|
|
384
|
+
}
|
|
385
|
+
if (entities.length === 1 && cross.length === 0) {
|
|
386
|
+
return "One entity. Invalidate stays on its own read surface, so the cross-entity check did not apply.";
|
|
387
|
+
}
|
|
388
|
+
if (cross.length > 0) {
|
|
389
|
+
const parents = unique(cross.map((l) => l.toEntity));
|
|
390
|
+
if (cross.length > 8) {
|
|
391
|
+
return `${entities.length} entities. ${parents.length} parents receive child writes (${cross.length} claims) — tested, not believed.`;
|
|
392
|
+
}
|
|
393
|
+
const pairs = unique(cross.map((l) => `${l.fromEntity} writes must change ${l.toEntity}`));
|
|
394
|
+
return `${entities.length} entities. ${pairs.join("; ")} — that claim was tested, not believed.`;
|
|
395
|
+
}
|
|
396
|
+
return `${entities.length} entities. Every independent window agreed.`;
|
|
397
|
+
}
|
|
398
|
+
function unique(values) {
|
|
399
|
+
return [...new Set(values)];
|
|
400
|
+
}
|
|
401
|
+
function mermaidFromGraph(entities, invalidate) {
|
|
402
|
+
const mid = (id) => id.replaceAll(/[^A-Za-z0-9_]/g, "_");
|
|
403
|
+
const lines = ["flowchart LR"];
|
|
404
|
+
if (entities.length > 24) {
|
|
405
|
+
const inbound = new Map();
|
|
406
|
+
for (const link of invalidate) {
|
|
407
|
+
if (link.cross)
|
|
408
|
+
inbound.set(link.toEntity, (inbound.get(link.toEntity) ?? 0) + 1);
|
|
409
|
+
}
|
|
410
|
+
for (const [parent, n] of [...inbound.entries()].sort((a, b) => b[1] - a[1]).slice(0, 24)) {
|
|
411
|
+
lines.push(` ${mid(parent)}(("${parent} ← ${n}"))`);
|
|
412
|
+
}
|
|
413
|
+
lines.push(` note["${entities.length} entities, compact"]`);
|
|
414
|
+
return lines.join("\n");
|
|
415
|
+
}
|
|
416
|
+
for (const entity of entities) {
|
|
417
|
+
lines.push(` ${mid(entity.name)}((${entity.name}))`);
|
|
418
|
+
}
|
|
419
|
+
for (const link of invalidate.filter((l) => l.cross)) {
|
|
420
|
+
lines.push(` ${mid(link.fromEntity)} -->|invalidates| ${mid(link.toEntity)}`);
|
|
421
|
+
}
|
|
422
|
+
const focus = entities.find((e) => invalidate.some((l) => l.cross && l.fromEntity === e.name)) ?? entities[0];
|
|
423
|
+
if (focus !== undefined) {
|
|
424
|
+
const byId = new Map(focus.nodes.map((n) => [n.id, n]));
|
|
425
|
+
for (const spec of NODE_ORDER) {
|
|
426
|
+
const node = byId.get(spec.id);
|
|
427
|
+
if (node === undefined)
|
|
428
|
+
continue;
|
|
429
|
+
const sid = mid(`${focus.name}__${spec.id}`);
|
|
430
|
+
lines.push(` ${sid}["${spec.label}"]`);
|
|
431
|
+
if (node.layer === "warp" || spec.id === "list.read-after-write" || spec.id === "tenant.item-not-readable-cross-tenant") {
|
|
432
|
+
lines.push(` ${mid(focus.name)} --> ${sid}`);
|
|
433
|
+
}
|
|
434
|
+
lines.push(` class ${sid} ${node.status}`);
|
|
435
|
+
}
|
|
436
|
+
for (const edge of USES) {
|
|
437
|
+
const dest = byId.get(edge.to);
|
|
438
|
+
const arrow = dest?.status === "blocked" || dest?.status === "skipped" ? "-.->" : "-->";
|
|
439
|
+
lines.push(` ${mid(`${focus.name}__${edge.from}`)} ${arrow} ${mid(`${focus.name}__${edge.to}`)}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
lines.push(" classDef held stroke:#7dcea0");
|
|
443
|
+
lines.push(" classDef failed stroke:#ff6b3d,color:#ff6b3d");
|
|
444
|
+
lines.push(" classDef blocked stroke:#6d7686,stroke-dasharray: 4 4");
|
|
445
|
+
lines.push(" classDef skipped stroke:#3d4758");
|
|
446
|
+
return lines.join("\n");
|
|
447
|
+
}
|
|
448
|
+
function renderPoster(graph) {
|
|
449
|
+
const multi = graph.entities.length > 1;
|
|
450
|
+
const crowded = graph.entities.length > 24;
|
|
451
|
+
const focus = graph.entities.find((e) => graph.invalidate.some((l) => l.cross && l.fromEntity === e.name))
|
|
452
|
+
?? graph.entities[0];
|
|
453
|
+
const strips = focus === undefined || crowded ? "" : stripHtml(focus, multi);
|
|
454
|
+
const loom = focus === undefined ? "" : loomSvg(focus);
|
|
455
|
+
const surface = (focus?.readSurface ?? []).map((r) => `<code>${esc(r)}</code>`).join("<span> · </span>");
|
|
456
|
+
return `<!DOCTYPE html>
|
|
457
|
+
<html lang="en">
|
|
458
|
+
<head>
|
|
459
|
+
<meta charset="utf-8">
|
|
460
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
461
|
+
<title>oat matrix — ${esc(graph.entities.length <= 8 ? graph.entities.map((e) => e.name).join(", ") : `${graph.entities.length} entities`)}</title>
|
|
462
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
463
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
464
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
|
|
465
|
+
<style>
|
|
466
|
+
:root {
|
|
467
|
+
--bg: #243044;
|
|
468
|
+
--ink: #f2ead8;
|
|
469
|
+
--mute: #9aa3b2;
|
|
470
|
+
--line: #4a5870;
|
|
471
|
+
--held: #7dcea0;
|
|
472
|
+
--fail: #ff6b3d;
|
|
473
|
+
--block: #6d7686;
|
|
474
|
+
--skip: #3d4758;
|
|
475
|
+
--pin: #f0c14b;
|
|
476
|
+
--card: #1b2332;
|
|
477
|
+
}
|
|
478
|
+
* { box-sizing: border-box; }
|
|
479
|
+
html, body { margin: 0; background: var(--bg); color: var(--ink); }
|
|
480
|
+
body { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.45; }
|
|
481
|
+
.poster { width: 1440px; margin: 0 auto; padding: 32px 44px 36px; }
|
|
482
|
+
header {
|
|
483
|
+
display: grid;
|
|
484
|
+
grid-template-columns: 1fr auto;
|
|
485
|
+
gap: 24px;
|
|
486
|
+
align-items: end;
|
|
487
|
+
border-bottom: 1px solid var(--line);
|
|
488
|
+
padding-bottom: 16px;
|
|
489
|
+
}
|
|
490
|
+
.kicker { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--mute); }
|
|
491
|
+
h1 {
|
|
492
|
+
font-family: Syne, sans-serif;
|
|
493
|
+
font-weight: 800;
|
|
494
|
+
font-size: 38px;
|
|
495
|
+
line-height: 0.95;
|
|
496
|
+
letter-spacing: -0.03em;
|
|
497
|
+
margin: 8px 0 10px;
|
|
498
|
+
}
|
|
499
|
+
.thesis { color: var(--ink); max-width: 44em; }
|
|
500
|
+
.counts { display: grid; grid-template-columns: repeat(4, auto); gap: 18px 28px; text-align: right; }
|
|
501
|
+
.counts b { display: block; font-family: Syne, sans-serif; font-size: 28px; font-weight: 700; }
|
|
502
|
+
.counts span { color: var(--mute); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
503
|
+
.counts .failed b { color: var(--fail); }
|
|
504
|
+
.counts .blocked b { color: var(--block); }
|
|
505
|
+
.counts .held b { color: var(--held); }
|
|
506
|
+
.band, .stage, .cards { margin-top: 18px; background: var(--card); border: 1px solid var(--line); }
|
|
507
|
+
.band-label, .stage-label {
|
|
508
|
+
color: var(--mute);
|
|
509
|
+
font-size: 11px;
|
|
510
|
+
letter-spacing: 0.14em;
|
|
511
|
+
text-transform: uppercase;
|
|
512
|
+
padding: 10px 16px 0;
|
|
513
|
+
}
|
|
514
|
+
svg { display: block; width: 100%; height: auto; }
|
|
515
|
+
.legend {
|
|
516
|
+
display: flex; gap: 22px; padding: 8px 16px 12px;
|
|
517
|
+
color: var(--mute); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
|
|
518
|
+
}
|
|
519
|
+
.swatch { display: inline-block; width: 10px; height: 10px; margin-right: 6px; vertical-align: -1px; }
|
|
520
|
+
.swatch.held { background: var(--held); }
|
|
521
|
+
.swatch.failed { background: var(--fail); }
|
|
522
|
+
.swatch.blocked { background: var(--block); box-shadow: inset 0 0 0 1px #9aa3b2; }
|
|
523
|
+
.swatch.skipped { background: var(--skip); }
|
|
524
|
+
.forest { padding: 16px 20px 20px; }
|
|
525
|
+
.forest-lead { color: var(--mute); margin: 0 0 14px; }
|
|
526
|
+
.bar-row { display: grid; grid-template-columns: 80px 1fr 90px; gap: 12px; align-items: center; margin: 6px 0; }
|
|
527
|
+
.bar-name { color: var(--pin); }
|
|
528
|
+
.bar-track { height: 8px; background: #2c3546; }
|
|
529
|
+
.bar-fill { height: 8px; background: #ff6b3d; }
|
|
530
|
+
.bar-n { color: var(--mute); font-size: 11px; }
|
|
531
|
+
.bar-more { color: var(--mute); margin-top: 8px; }
|
|
532
|
+
.crowd-note { margin-top: 16px; color: var(--mute); }
|
|
533
|
+
.cards { display: grid; grid-template-columns: repeat(${Math.min(Math.max(graph.entities.length, 1), 5)}, minmax(200px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
|
|
534
|
+
.card { background: var(--card); padding: 14px 16px 16px; }
|
|
535
|
+
.card h2 { font-family: Syne, sans-serif; font-size: 18px; margin: 0 0 6px; }
|
|
536
|
+
.card .meta { color: var(--mute); font-size: 11px; margin-bottom: 10px; }
|
|
537
|
+
.dots { display: flex; gap: 5px; flex-wrap: wrap; }
|
|
538
|
+
.dot { width: 12px; height: 12px; border: 1px solid var(--line); }
|
|
539
|
+
.dot.held { background: var(--held); border-color: #3f6b55; }
|
|
540
|
+
.dot.failed { background: var(--fail); border-color: var(--fail); }
|
|
541
|
+
.dot.blocked { background: transparent; border-style: dashed; border-color: var(--block); }
|
|
542
|
+
.dot.skipped { background: var(--skip); }
|
|
543
|
+
.strips { margin-top: 18px; display: grid; gap: 10px; }
|
|
544
|
+
.strip { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: start; }
|
|
545
|
+
.strip-title { color: var(--mute); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; padding-top: 6px; }
|
|
546
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
547
|
+
.chip { border: 1px solid var(--line); padding: 3px 8px; color: var(--mute); }
|
|
548
|
+
.chip.held { color: var(--held); border-color: #3f6b55; }
|
|
549
|
+
.chip.failed { color: var(--fail); border-color: var(--fail); background: #3a221c; }
|
|
550
|
+
.chip.blocked { color: var(--block); border-style: dashed; }
|
|
551
|
+
.chip.skipped { opacity: 0.45; }
|
|
552
|
+
footer {
|
|
553
|
+
margin-top: 18px; color: var(--mute); font-size: 11px;
|
|
554
|
+
display: flex; justify-content: space-between; gap: 16px;
|
|
555
|
+
border-top: 1px solid var(--line); padding-top: 12px;
|
|
556
|
+
}
|
|
557
|
+
footer code { color: var(--ink); }
|
|
558
|
+
</style>
|
|
559
|
+
</head>
|
|
560
|
+
<body>
|
|
561
|
+
<article class="poster">
|
|
562
|
+
<header>
|
|
563
|
+
<div>
|
|
564
|
+
<div class="kicker">${multi ? "many entities · invalidate is an edge" : "same fact · many windows"}</div>
|
|
565
|
+
<h1>${headerTitle(graph)}</h1>
|
|
566
|
+
<p class="thesis">${esc(graph.thesis)}</p>
|
|
567
|
+
</div>
|
|
568
|
+
<div class="counts">
|
|
569
|
+
<div class="failed"><b>${graph.counts.failed}</b><span>disagreed</span></div>
|
|
570
|
+
<div class="blocked"><b>${graph.counts.blocked}</b><span>not woven</span></div>
|
|
571
|
+
<div class="held"><b>${graph.counts.held}</b><span>agreed</span></div>
|
|
572
|
+
<div class="skipped"><b>${graph.counts.skipped}</b><span>no window</span></div>
|
|
573
|
+
</div>
|
|
574
|
+
</header>
|
|
575
|
+
|
|
576
|
+
<div class="band">
|
|
577
|
+
<div class="band-label">entities and x-invalidate</div>
|
|
578
|
+
${entityBand(graph)}
|
|
579
|
+
<div class="legend">
|
|
580
|
+
<span><i class="swatch held"></i>own read surface</span>
|
|
581
|
+
<span><i class="swatch failed"></i>cross-entity invalidate — the check that needs two entities</span>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
|
|
585
|
+
${multi && !crowded ? entityCards(graph) : ""}
|
|
586
|
+
${crowded ? forestSummary(graph) : ""}
|
|
587
|
+
|
|
588
|
+
<div class="stage">
|
|
589
|
+
<div class="stage-label">${focus === undefined ? "loom" : `loom — ${focus.name}`}</div>
|
|
590
|
+
${loom}
|
|
591
|
+
<div class="legend">
|
|
592
|
+
<span><i class="swatch held"></i>agreed</span>
|
|
593
|
+
<span><i class="swatch failed"></i>disagreed — a finding</span>
|
|
594
|
+
<span><i class="swatch blocked"></i>not woven — waiting on a snapped thread</span>
|
|
595
|
+
<span><i class="swatch skipped"></i>no window in this document</span>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<div class="strips">${strips}</div>
|
|
600
|
+
|
|
601
|
+
<footer>
|
|
602
|
+
<div>${surface === "" ? "" : `read surface ${surface}`}</div>
|
|
603
|
+
<div>${esc(graph.baseUrl)} · ${esc(graph.generatedAt)}</div>
|
|
604
|
+
</footer>
|
|
605
|
+
</article>
|
|
606
|
+
</body>
|
|
607
|
+
</html>
|
|
608
|
+
`;
|
|
609
|
+
}
|
|
610
|
+
function headerTitle(graph) {
|
|
611
|
+
if (graph.entities.length <= 1) {
|
|
612
|
+
const name = graph.entities[0]?.name ?? "entity";
|
|
613
|
+
return `${esc(name)} was asked<br>through independent projections`;
|
|
614
|
+
}
|
|
615
|
+
const cross = graph.invalidate.filter((l) => l.cross).length;
|
|
616
|
+
return `${graph.entities.length} entities, ${cross} cross-entity claim${cross === 1 ? "" : "s"}`;
|
|
617
|
+
}
|
|
618
|
+
function entityBand(graph) {
|
|
619
|
+
if (graph.entities.length > 24)
|
|
620
|
+
return crowdedBand(graph);
|
|
621
|
+
const names = graph.entities.map((e) => e.name);
|
|
622
|
+
const n = Math.max(names.length, 1);
|
|
623
|
+
const width = 1120;
|
|
624
|
+
const perRow = n <= 6 ? n : n <= 12 ? Math.ceil(n / 2) : 5;
|
|
625
|
+
const rows = Math.ceil(n / perRow);
|
|
626
|
+
const rowH = 96;
|
|
627
|
+
const top = 56;
|
|
628
|
+
const pos = new Map(names.map((name, i) => {
|
|
629
|
+
const row = Math.floor(i / perRow);
|
|
630
|
+
const col = i % perRow;
|
|
631
|
+
const inRow = Math.min(perRow, n - row * perRow);
|
|
632
|
+
const slot = width / (inRow + 1);
|
|
633
|
+
return [name, { x: slot * (col + 1), y: top + row * rowH }];
|
|
634
|
+
}));
|
|
635
|
+
const height = Math.max(160, top + rows * rowH + 20);
|
|
636
|
+
const pins = names.map((name) => {
|
|
637
|
+
const at = pos.get(name);
|
|
638
|
+
if (at === undefined)
|
|
639
|
+
return "";
|
|
640
|
+
const slice = graph.entities.find((e) => e.name === name);
|
|
641
|
+
const fill = "#f0c14b";
|
|
642
|
+
const surfaces = (slice?.readSurface.length ?? 0);
|
|
643
|
+
return `<g>
|
|
644
|
+
<circle cx="${at.x}" cy="${at.y}" r="16" fill="${fill}"/>
|
|
645
|
+
<circle cx="${at.x}" cy="${at.y}" r="6" fill="#243044"/>
|
|
646
|
+
<text x="${at.x}" y="${at.y + 36}" text-anchor="middle" fill="${fill}" font-size="14" font-family="Syne, sans-serif" font-weight="700">${esc(name)}</text>
|
|
647
|
+
<text x="${at.x}" y="${at.y + 52}" text-anchor="middle" fill="#9aa3b2" font-size="10" font-family="IBM Plex Mono, monospace">${surfaces} read surface${surfaces === 1 ? "" : "s"}</text>
|
|
648
|
+
</g>`;
|
|
649
|
+
});
|
|
650
|
+
const pairs = new Map();
|
|
651
|
+
for (const link of graph.invalidate) {
|
|
652
|
+
if (!link.cross)
|
|
653
|
+
continue;
|
|
654
|
+
pairs.set(`${link.fromEntity}\0${link.toEntity}`, { from: link.fromEntity, to: link.toEntity });
|
|
655
|
+
}
|
|
656
|
+
const labelArrows = names.length <= 8;
|
|
657
|
+
const arrows = [...pairs.values()].map((link) => {
|
|
658
|
+
const from = pos.get(link.from);
|
|
659
|
+
const to = pos.get(link.to);
|
|
660
|
+
if (from === undefined || to === undefined)
|
|
661
|
+
return "";
|
|
662
|
+
const midX = (from.x + to.x) / 2;
|
|
663
|
+
const lift = from.y === to.y ? 36 : Math.abs(to.y - from.y) / 2 + 16;
|
|
664
|
+
const midY = Math.min(from.y, to.y) - lift;
|
|
665
|
+
const label = labelArrows
|
|
666
|
+
? `<text x="${midX}" y="${midY - 4}" text-anchor="middle" fill="#ff6b3d" font-size="11" font-family="IBM Plex Mono, monospace">${esc(`${link.from} → ${link.to}`)}</text>`
|
|
667
|
+
: "";
|
|
668
|
+
return `<path d="M ${from.x} ${from.y - 16} Q ${midX} ${midY}, ${to.x} ${to.y - 16}" fill="none" stroke="#ff6b3d" stroke-width="1.6" marker-end="url(#arrow-cross)"/>${label}`;
|
|
669
|
+
});
|
|
670
|
+
const cross = graph.invalidate.filter((l) => l.cross);
|
|
671
|
+
const empty = cross.length === 0
|
|
672
|
+
? `<text x="560" y="36" text-anchor="middle" fill="#9aa3b2" font-size="12" font-family="IBM Plex Mono, monospace">${graph.invalidate.length === 0
|
|
673
|
+
? "no x-invalidate edges in this document"
|
|
674
|
+
: "invalidate stays on the same entity — the cross-entity check did not apply"}</text>`
|
|
675
|
+
: "";
|
|
676
|
+
return `<svg viewBox="0 0 1120 ${height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Entity invalidate graph">
|
|
677
|
+
<defs>
|
|
678
|
+
<marker id="arrow-cross" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#ff6b3d"/></marker>
|
|
679
|
+
<marker id="arrow-self" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#6d7686"/></marker>
|
|
680
|
+
</defs>
|
|
681
|
+
${empty}
|
|
682
|
+
${arrows.join("\n")}
|
|
683
|
+
${pins.join("\n")}
|
|
684
|
+
</svg>`;
|
|
685
|
+
}
|
|
686
|
+
function crowdedBand(graph) {
|
|
687
|
+
const inbound = new Map();
|
|
688
|
+
for (const link of graph.invalidate) {
|
|
689
|
+
if (!link.cross)
|
|
690
|
+
continue;
|
|
691
|
+
const list = inbound.get(link.toEntity) ?? [];
|
|
692
|
+
if (!list.includes(link.fromEntity))
|
|
693
|
+
list.push(link.fromEntity);
|
|
694
|
+
inbound.set(link.toEntity, list);
|
|
695
|
+
}
|
|
696
|
+
const parents = [...inbound.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
697
|
+
const max = Math.max(1, ...parents.map(([, w]) => w.length));
|
|
698
|
+
const rows = parents
|
|
699
|
+
.slice(0, 24)
|
|
700
|
+
.map(([parent, writers]) => {
|
|
701
|
+
const width = Math.max(4, Math.round((writers.length / max) * 240));
|
|
702
|
+
return `<div class="bar-row">
|
|
703
|
+
<div class="bar-name">${esc(parent)}</div>
|
|
704
|
+
<div class="bar-track"><div class="bar-fill" style="width:${width}px"></div></div>
|
|
705
|
+
<div class="bar-n">${writers.length} writers</div>
|
|
706
|
+
</div>`;
|
|
707
|
+
})
|
|
708
|
+
.join("");
|
|
709
|
+
const more = parents.length > 24 ? `<div class="bar-more">${parents.length - 24} more parents</div>` : "";
|
|
710
|
+
const orphans = graph.entities.length - new Set([...inbound.keys(), ...[...inbound.values()].flat()]).size;
|
|
711
|
+
return `<div class="forest">
|
|
712
|
+
<p class="forest-lead">${graph.entities.length} entities · ${graph.invalidate.filter((l) => l.cross).length} cross-entity claims · ${parents.length} parents · ${orphans} with no invalidate edge</p>
|
|
713
|
+
${rows}${more}
|
|
714
|
+
</div>`;
|
|
715
|
+
}
|
|
716
|
+
function forestSummary(graph) {
|
|
717
|
+
const failed = graph.entities.filter((e) => e.counts.failed > 0);
|
|
718
|
+
const blocked = graph.entities.filter((e) => e.counts.blocked > 0 && e.counts.failed === 0);
|
|
719
|
+
const held = graph.entities.filter((e) => e.counts.failed === 0 && e.counts.blocked === 0);
|
|
720
|
+
const cards = [...failed, ...blocked.slice(0, 8)].map((entity) => {
|
|
721
|
+
const outbound = graph.invalidate.filter((l) => l.fromEntity === entity.name && l.cross);
|
|
722
|
+
const inv = outbound.length === 0
|
|
723
|
+
? "does not invalidate another entity"
|
|
724
|
+
: `invalidates ${unique(outbound.map((l) => l.toEntity)).join(", ")}`;
|
|
725
|
+
return `<div class="card">
|
|
726
|
+
<h2>${esc(entity.name)}</h2>
|
|
727
|
+
<div class="meta">${entity.counts.failed} disagreed · ${entity.counts.blocked} stood down · ${esc(inv)}</div>
|
|
728
|
+
</div>`;
|
|
729
|
+
});
|
|
730
|
+
return `<div class="crowd-note">
|
|
731
|
+
${held.length} entities agreed on every window that applied.
|
|
732
|
+
${failed.length === 0 ? "Showing the loom for one writer, not 200 pins." : `Showing ${failed.length} entities that disagreed.`}
|
|
733
|
+
</div>
|
|
734
|
+
${cards.length > 0 ? `<div class="cards">${cards.join("")}</div>` : ""}`;
|
|
735
|
+
}
|
|
736
|
+
function entityCards(graph) {
|
|
737
|
+
const cards = graph.entities.map((entity) => {
|
|
738
|
+
const outbound = graph.invalidate.filter((l) => l.fromEntity === entity.name && l.cross);
|
|
739
|
+
const inv = outbound.length === 0
|
|
740
|
+
? "does not invalidate another entity"
|
|
741
|
+
: `invalidates ${unique(outbound.map((l) => l.toEntity)).join(", ")}`;
|
|
742
|
+
const loomDots = NODE_ORDER.map((spec) => {
|
|
743
|
+
const status = entity.nodes.find((n) => n.id === spec.id)?.status ?? "skipped";
|
|
744
|
+
return `<span class="dot ${status}" title="${esc(spec.label)}"></span>`;
|
|
745
|
+
}).join("");
|
|
746
|
+
return `<div class="card">
|
|
747
|
+
<h2>${esc(entity.name)}</h2>
|
|
748
|
+
<div class="meta">${entity.identity === null ? "no identity" : `id ${esc(entity.name)}.${esc(entity.identity)}`} · ${entity.readSurface.length} surfaces · ${esc(inv)}</div>
|
|
749
|
+
<div class="dots">${loomDots}</div>
|
|
750
|
+
</div>`;
|
|
751
|
+
});
|
|
752
|
+
return `<div class="cards">${cards.join("")}</div>`;
|
|
753
|
+
}
|
|
754
|
+
function stripHtml(focus, multi) {
|
|
755
|
+
const interesting = (id, status) => !multi || status === "failed" || status === "blocked";
|
|
756
|
+
return STRIPS.map((strip) => {
|
|
757
|
+
const chips = strip.ids
|
|
758
|
+
.map((id) => {
|
|
759
|
+
const node = focus.nodes.find((n) => n.id === id);
|
|
760
|
+
const status = node?.status ?? "skipped";
|
|
761
|
+
if (!interesting(id, status))
|
|
762
|
+
return "";
|
|
763
|
+
const short = id.split(".").slice(1).join(".");
|
|
764
|
+
const extra = node?.because;
|
|
765
|
+
const title = extra !== undefined ? `${id} — waiting on ${extra}` : id;
|
|
766
|
+
return `<span class="chip ${status}" title="${esc(title)}">${esc(short)}</span>`;
|
|
767
|
+
})
|
|
768
|
+
.filter(Boolean)
|
|
769
|
+
.join("");
|
|
770
|
+
if (chips === "")
|
|
771
|
+
return "";
|
|
772
|
+
return `<div class="strip"><div class="strip-title">${esc(strip.title)}</div><div class="chips">${chips}</div></div>`;
|
|
773
|
+
}).join("");
|
|
774
|
+
}
|
|
775
|
+
function loomSvg(focus) {
|
|
776
|
+
const byId = new Map(focus.nodes.map((n) => [n.id, n]));
|
|
777
|
+
const st = (id) => byId.get(id)?.status ?? "skipped";
|
|
778
|
+
const pin = { x: 130, y: 270 };
|
|
779
|
+
const warp = [
|
|
780
|
+
{ id: "tenant.item-not-readable-cross-tenant", x: 130, y: 70 },
|
|
781
|
+
{ id: "list.read-after-write", x: 130, y: 150 },
|
|
782
|
+
{ id: "consistency.projections-agree", x: 130, y: 390 },
|
|
783
|
+
{ id: "filter.equality-selects-exactly-one", x: 390, y: 90 },
|
|
784
|
+
{ id: "sort.order-is-applied", x: 390, y: 180 },
|
|
785
|
+
{ id: "select.projection-honoured", x: 390, y: 270 },
|
|
786
|
+
{ id: "search.q-narrows-result", x: 390, y: 360 },
|
|
787
|
+
{ id: "pagination.page-walk-covers-set", x: 390, y: 450 },
|
|
788
|
+
];
|
|
789
|
+
const weft = [
|
|
790
|
+
{ from: ["filter.equality-selects-exactly-one", "sort.order-is-applied"], id: "query.axes-compose", x: 700, y: 110 },
|
|
791
|
+
{ from: ["filter.equality-selects-exactly-one", "select.projection-honoured"], id: "query.filter-and-select-compose", x: 700, y: 220 },
|
|
792
|
+
{ from: ["filter.equality-selects-exactly-one", "search.q-narrows-result"], id: "query.search-and-filter-compose", x: 700, y: 330 },
|
|
793
|
+
{ from: ["filter.equality-selects-exactly-one", "pagination.page-walk-covers-set"], id: "query.filter-selects-from-whole-set", x: 700, y: 450 },
|
|
794
|
+
{ from: ["query.axes-compose", "query.filter-and-select-compose"], id: "query.filter-sort-select-compose", x: 980, y: 160 },
|
|
795
|
+
{ from: ["query.axes-compose", "query.search-and-filter-compose"], id: "query.filter-search-sort-compose", x: 980, y: 270 },
|
|
796
|
+
{ from: ["query.filter-and-select-compose", "query.search-and-filter-compose"], id: "query.filter-search-select-compose", x: 980, y: 380 },
|
|
797
|
+
];
|
|
798
|
+
const loc = new Map();
|
|
799
|
+
for (const node of warp)
|
|
800
|
+
loc.set(node.id, node);
|
|
801
|
+
for (const node of weft)
|
|
802
|
+
loc.set(node.id, node);
|
|
803
|
+
const lines = [];
|
|
804
|
+
for (const node of warp) {
|
|
805
|
+
const surface = node.x < 200;
|
|
806
|
+
if (surface)
|
|
807
|
+
lines.push(link(pin, node, st(node.id)));
|
|
808
|
+
else
|
|
809
|
+
lines.push(link(pin, node, st(node.id)));
|
|
810
|
+
}
|
|
811
|
+
for (const node of weft) {
|
|
812
|
+
for (const src of node.from) {
|
|
813
|
+
const from = loc.get(src);
|
|
814
|
+
if (from !== undefined)
|
|
815
|
+
lines.push(link(from, node, st(node.id)));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const stations = [...warp, ...weft].map((pt) => {
|
|
819
|
+
const spec = NODE_ORDER.find((s) => s.id === pt.id);
|
|
820
|
+
const status = st(pt.id);
|
|
821
|
+
return box(pt, spec?.label ?? pt.id, status === "failed" ? "disagreed" : status === "blocked" ? "" : spec?.hint ?? "", status);
|
|
822
|
+
});
|
|
823
|
+
return `<svg viewBox="0 0 1120 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Query loom for ${esc(focus.name)}">
|
|
824
|
+
<text x="48" y="24" fill="#9aa3b2" font-size="11" letter-spacing="2" font-family="IBM Plex Mono, monospace">WINDOWS</text>
|
|
825
|
+
<text x="360" y="24" fill="#9aa3b2" font-size="11" letter-spacing="2" font-family="IBM Plex Mono, monospace">WARP</text>
|
|
826
|
+
<text x="650" y="24" fill="#9aa3b2" font-size="11" letter-spacing="2" font-family="IBM Plex Mono, monospace">PAIRS</text>
|
|
827
|
+
<text x="930" y="24" fill="#9aa3b2" font-size="11" letter-spacing="2" font-family="IBM Plex Mono, monospace">TRIPLES</text>
|
|
828
|
+
${lines.join("\n")}
|
|
829
|
+
<circle cx="${pin.x}" cy="${pin.y}" r="18" fill="#f0c14b"/>
|
|
830
|
+
<circle cx="${pin.x}" cy="${pin.y}" r="7" fill="#243044"/>
|
|
831
|
+
<text x="${pin.x}" y="${pin.y + 38}" text-anchor="middle" fill="#f0c14b" font-size="13" font-family="Syne, sans-serif" font-weight="700">${esc(focus.name)}</text>
|
|
832
|
+
<text x="${pin.x}" y="${pin.y + 54}" text-anchor="middle" fill="#9aa3b2" font-size="10" font-family="IBM Plex Mono, monospace">one cohort</text>
|
|
833
|
+
${stations.join("\n")}
|
|
834
|
+
</svg>`;
|
|
835
|
+
}
|
|
836
|
+
function link(from, to, status) {
|
|
837
|
+
const color = status === "failed" ? "#ff6b3d" : status === "held" ? "#7dcea0" : status === "blocked" ? "#6d7686" : "#3d4758";
|
|
838
|
+
const dash = status === "blocked" || status === "skipped" ? 'stroke-dasharray="5 5"' : "";
|
|
839
|
+
return `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${color}" stroke-width="1.6" ${dash}/>`;
|
|
840
|
+
}
|
|
841
|
+
function box(pt, label, caption, status) {
|
|
842
|
+
const stroke = status === "failed" ? "#ff6b3d" : status === "held" ? "#7dcea0" : status === "blocked" ? "#6d7686" : "#3d4758";
|
|
843
|
+
const fill = status === "failed" ? "#ff6b3d" : "#1b2332";
|
|
844
|
+
const ink = status === "failed" ? "#243044" : status === "held" ? "#7dcea0" : "#9aa3b2";
|
|
845
|
+
const dash = status === "blocked" || status === "skipped" ? 'stroke-dasharray="3 3"' : "";
|
|
846
|
+
const w = Math.max(92, label.length * 7.4 + 22);
|
|
847
|
+
const x = pt.x - w / 2;
|
|
848
|
+
const y = pt.y - 15;
|
|
849
|
+
const cap = caption === ""
|
|
850
|
+
? ""
|
|
851
|
+
: `<text x="${pt.x}" y="${pt.y + 28}" text-anchor="middle" fill="#9aa3b2" font-size="10" font-family="IBM Plex Mono, monospace">${esc(caption)}</text>`;
|
|
852
|
+
return `<g>
|
|
853
|
+
<rect x="${x}" y="${y}" width="${w}" height="30" rx="2" fill="${fill}" stroke="${stroke}" ${dash}/>
|
|
854
|
+
<text x="${pt.x}" y="${pt.y + 5}" text-anchor="middle" fill="${ink}" font-size="12" font-family="Syne, sans-serif" font-weight="700">${esc(label)}</text>
|
|
855
|
+
${cap}
|
|
856
|
+
</g>`;
|
|
857
|
+
}
|
|
858
|
+
function esc(value) {
|
|
859
|
+
return value
|
|
860
|
+
.replaceAll("&", "&")
|
|
861
|
+
.replaceAll("<", "<")
|
|
862
|
+
.replaceAll(">", ">")
|
|
863
|
+
.replaceAll('"', """);
|
|
864
|
+
}
|
|
865
|
+
//# sourceMappingURL=matrix.js.map
|