@crvouga/atlas 1.14.0 → 1.16.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/catalog-health.d.ts +15 -2
  3. package/dist/reports/src/catalog-health.js +23 -2
  4. package/dist/reports/src/evidence-detail.d.ts +19 -0
  5. package/dist/reports/src/evidence-detail.js +31 -4
  6. package/dist/reports/src/evidence-types.d.ts +2 -2
  7. package/dist/reports/src/layer-merge.js +4 -3
  8. package/dist/reports/src/layer-store.d.ts +1 -1
  9. package/dist/reports/src/layer-store.js +4 -2
  10. package/dist/reports/src/layer-types.d.ts +2 -0
  11. package/dist/reports/src/run-compare.d.ts +182 -0
  12. package/dist/reports/src/run-compare.js +329 -0
  13. package/dist/reports/src/test-catalog.d.ts +8 -0
  14. package/dist/reports/src/test-catalog.js +14 -1
  15. package/dist/schema/src/api.d.ts +10 -0
  16. package/dist/schema/src/api.js +15 -5
  17. package/dist/schema/src/server-config.d.ts +3 -0
  18. package/dist/schema/src/server-config.js +5 -0
  19. package/dist/server/src/app.d.ts +2 -0
  20. package/dist/server/src/app.js +6 -2
  21. package/dist/server/src/config.js +4 -0
  22. package/dist/server/src/main.js +1 -0
  23. package/dist/server/src/openapi.js +2 -2
  24. package/docs/extending.md +1 -1
  25. package/docs/reference/http-api.md +4 -4
  26. package/docs/reference/reports.md +378 -5
  27. package/docs/reference/server.md +2 -0
  28. package/docs/server.md +11 -2
  29. package/docs/ui.md +49 -1
  30. package/llms-full.txt +65 -8
  31. package/package.json +1 -1
  32. package/packages/schema/schemas/catalog-health.schema.json +20 -0
  33. package/packages/schema/schemas/effective-delta.schema.json +2 -2
  34. package/packages/schema/schemas/effective-evidence.schema.json +2 -2
  35. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  36. package/packages/schema/schemas/layer-rows.schema.json +1 -1
  37. package/packages/schema/schemas/report-run.schema.json +25 -3
  38. package/packages/schema/schemas/report-runs.schema.json +1 -1
  39. package/packages/schema/schemas/review-collection.schema.json +1 -1
  40. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  41. package/packages/schema/schemas/server-config.schema.json +17 -0
  42. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  43. package/packages/schema/schemas/test-history.schema.json +2 -2
  44. package/packages/server/openapi.json +63 -21
  45. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  46. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  47. package/packages/visualizer/src/explorer/Explorer.tsx +13 -2
  48. package/packages/visualizer/src/explorer/HealthView.tsx +402 -0
  49. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  50. package/packages/visualizer/src/explorer/catalog.tsx +33 -0
  51. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  52. package/packages/visualizer/src/explorer/search.ts +18 -3
@@ -0,0 +1,329 @@
1
+ import { compareLatency, groupOf, latencyObservations, latencyReview } from './latency.js';
2
+ /** Where a pair of results (or one result with no partner) stands, in the order a review reads them. */
3
+ export const COMPARE_GROUPS = ['regressed', 'recovered', 'still-failing', 'not-comparable', 'unknown', 'removed', 'added', 'unchanged'];
4
+ /** What each group is called and what it does and does not say. */
5
+ export const COMPARE_GROUP_TEXT = {
6
+ regressed: { label: 'Newly failing', meaning: 'Passed in the baseline run and failed in the candidate run, under the same conditions and the same wording.' },
7
+ recovered: { label: 'Recovered', meaning: 'Failed in the baseline run and passed in the candidate run. One later pass is not a proven fix.' },
8
+ 'still-failing': { label: 'Still failing', meaning: 'Failed in both runs.' },
9
+ 'not-comparable': { label: 'Promise or conditions differ', meaning: 'The behavior is worded differently, or the two results were recorded under different conditions, so the two outcomes are not the same experiment and no regression or recovery is claimed.' },
10
+ unknown: { label: 'Not run or unfinished', meaning: 'One side did not run it or it is still running. That is unknown: not a failure and not a pass.' },
11
+ removed: { label: 'Not in the candidate run', meaning: 'Only the baseline run recorded it. The candidate may not have run it, or it was renamed or dropped: unknown, not a regression and not a pass.' },
12
+ added: { label: 'Not in the baseline run', meaning: 'Only the candidate run recorded it. It may be new, or the baseline may not have run it.' },
13
+ unchanged: { label: 'Same outcome', meaning: 'Passed in both runs, or not run in both.' }
14
+ };
15
+ /** The condition of a result. A server that does not send `kind` leaves it null: unknown, equal only to another unknown. */
16
+ export function conditionOf(result) {
17
+ return { kind: result.kind ?? null, producer: result.producer, environment: result.environment, parameters: result.parameters ?? {} };
18
+ }
19
+ /** A stable text for a condition, equal exactly when two conditions are. */
20
+ export function conditionId(condition) {
21
+ const parameters = Object.keys(condition.parameters).sort().map(name => `${name}=${condition.parameters[name]}`).join(',');
22
+ return [condition.kind ?? '', condition.producer, condition.environment ?? '', parameters].join('/');
23
+ }
24
+ /** How two conditions differ, in words; empty when they are the same. */
25
+ export function conditionDifferences(baseline, candidate) {
26
+ const out = [];
27
+ if (baseline.environment !== candidate.environment)
28
+ out.push(`environment ${baseline.environment ?? 'not declared'} against ${candidate.environment ?? 'not declared'}`);
29
+ if (baseline.producer !== candidate.producer)
30
+ out.push(`producer ${baseline.producer} against ${candidate.producer}`);
31
+ if ((baseline.kind ?? '') !== (candidate.kind ?? ''))
32
+ out.push(`kind ${baseline.kind ?? 'not declared'} against ${candidate.kind ?? 'not declared'}`);
33
+ for (const name of [...new Set([...Object.keys(baseline.parameters), ...Object.keys(candidate.parameters)])].sort()) {
34
+ if (baseline.parameters[name] !== candidate.parameters[name])
35
+ out.push(`${name} ${baseline.parameters[name] ?? 'not set'} against ${candidate.parameters[name] ?? 'not set'}`);
36
+ }
37
+ return out;
38
+ }
39
+ const completed = (status) => status === 'passed' || status === 'failed';
40
+ const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
41
+ /** Short form of a spec fingerprint, to show. */
42
+ export function shortRevision(revision) {
43
+ return revision ? revision.slice(0, 8) : 'not recorded';
44
+ }
45
+ /** The wording a run recorded for a check: what that run's layer called it, else the check's newest title. */
46
+ export function recordedTitle(result) {
47
+ return result.recordedTitle ?? result.title;
48
+ }
49
+ function readPromise(baseline, candidate) {
50
+ const baselineTitle = baseline ? recordedTitle(baseline) : null;
51
+ const candidateTitle = candidate ? recordedTitle(candidate) : null;
52
+ const baselineRevision = baseline?.specRevision ?? null;
53
+ const candidateRevision = candidate?.specRevision ?? null;
54
+ const reasons = [];
55
+ const base = { baselineTitle, candidateTitle, baselineRevision, candidateRevision };
56
+ if (!baseline || !candidate)
57
+ return { ...base, state: 'unknown', reasons: ['Only one run recorded this behavior, so its promise cannot be compared.'] };
58
+ const titleDiffers = baselineTitle !== candidateTitle;
59
+ const revisionKnown = baselineRevision !== null && candidateRevision !== null;
60
+ const revisionDiffers = revisionKnown && baselineRevision !== candidateRevision;
61
+ if (titleDiffers)
62
+ reasons.push(`The behavior is worded differently: “${baselineTitle}” became “${candidateTitle}”.`);
63
+ if (revisionDiffers)
64
+ reasons.push(`The spec file was recorded at revision ${shortRevision(baselineRevision)} and then ${shortRevision(candidateRevision)}. A revision is of the whole file, so it does not say that this behavior's own promise changed.`);
65
+ if (titleDiffers)
66
+ return { ...base, state: 'changed', reasons };
67
+ if (revisionDiffers)
68
+ return { ...base, state: 'revision-differs', reasons };
69
+ if (!revisionKnown) {
70
+ reasons.push(`${baselineRevision === null && candidateRevision === null ? 'Neither run' : baselineRevision === null ? 'The baseline run' : 'The candidate run'} recorded no spec revision, so whether the promise is the same is unknown.`);
71
+ return { ...base, state: 'unknown', reasons };
72
+ }
73
+ return { ...base, state: 'same', reasons };
74
+ }
75
+ function movement(baseline, candidate) {
76
+ const b = baseline.status;
77
+ const c = candidate.status;
78
+ if (!completed(b) || !completed(c)) {
79
+ const where = !completed(c) ? 'candidate' : 'baseline';
80
+ const what = (status) => (status === 'running' ? 'still running' : 'not run');
81
+ const side = !completed(c) ? c : b;
82
+ return { group: 'unknown', summary: `${what(side)[0].toUpperCase()}${what(side).slice(1)} in the ${where} run, so there is no outcome to compare. That is unknown, not a failure or a pass.` };
83
+ }
84
+ if (b === 'passed' && c === 'failed')
85
+ return { group: 'regressed', summary: 'Passed in the baseline run, failed in the candidate run.' };
86
+ if (b === 'failed' && c === 'passed')
87
+ return { group: 'recovered', summary: 'Failed in the baseline run, passed in the candidate run.' };
88
+ if (b === 'failed')
89
+ return { group: 'still-failing', summary: 'Failed in both runs.' };
90
+ return { group: 'unchanged', summary: 'Passed in both runs.' };
91
+ }
92
+ function pairOf(key, baseline, candidate, differences) {
93
+ const promise = readPromise(baseline, candidate);
94
+ const id = [key, baseline ? conditionId(conditionOf(baseline)) : '', candidate ? conditionId(conditionOf(candidate)) : ''].join('\u0001');
95
+ const title = candidate ? recordedTitle(candidate) : recordedTitle(baseline);
96
+ const specPath = (candidate ?? baseline).specPath;
97
+ const caveats = [];
98
+ const common = { id, key, title, specPath, baseline, candidate, promise, conditions: differences, caveats };
99
+ if (!baseline)
100
+ return { ...common, group: 'added', summary: 'Only the candidate run recorded it. It may be new, or the baseline may not have run it.' };
101
+ if (!candidate)
102
+ return { ...common, group: 'removed', summary: 'The candidate run recorded no result for it. It may not have run it, or it was renamed or dropped: unknown, not a regression.' };
103
+ if (promise.state === 'changed' || differences.length > 0) {
104
+ const why = [...(promise.state === 'changed' ? ['the behavior is worded differently'] : []), ...(differences.length ? [`it was recorded under different conditions (${differences.join('; ')})`] : [])];
105
+ return { ...common, group: 'not-comparable', summary: `Not the same experiment: ${why.join(' and ')}. Both outcomes are shown; no regression or recovery is claimed.` };
106
+ }
107
+ const moved = movement(baseline, candidate);
108
+ if (promise.state === 'revision-differs')
109
+ caveats.push(...promise.reasons);
110
+ else if (promise.state === 'unknown' && moved.group !== 'unknown')
111
+ caveats.push(...promise.reasons);
112
+ return { ...common, ...moved };
113
+ }
114
+ /**
115
+ * Pairs the results of two runs and reads what changed. Results are paired by check identity and then by condition (see
116
+ * the module comment); titles never pair two results. Each side's results are those the run recorded last for each
117
+ * check and condition (`RunDetail.results`).
118
+ *
119
+ * @param baseline - The run to compare against.
120
+ * @param candidate - The run under review. Both are used as given: a newer run never replaces either.
121
+ * @returns Every result of either run, grouped by what changed, with the reasons.
122
+ * @example
123
+ * ```ts
124
+ * const comparison = compareRuns(await runDetail('run:ci-100'), await runDetail('run:ci-101'));
125
+ * comparison.results.filter(item => item.group === 'regressed');
126
+ * ```
127
+ */
128
+ export function compareRuns(baseline, candidate) {
129
+ const byKey = (results) => {
130
+ const map = new Map();
131
+ for (const result of results) {
132
+ const list = map.get(result.key);
133
+ if (list)
134
+ list.push(result);
135
+ else
136
+ map.set(result.key, [result]);
137
+ }
138
+ return map;
139
+ };
140
+ const left = byKey(baseline.results);
141
+ const right = byKey(candidate.results);
142
+ const pairs = [];
143
+ for (const key of [...new Set([...left.keys(), ...right.keys()])]) {
144
+ const sides = { base: [...(left.get(key) ?? [])], cand: [...(right.get(key) ?? [])] };
145
+ const order = (a, b) => compareText(conditionId(conditionOf(a)), conditionId(conditionOf(b))) || compareText(a.layer, b.layer);
146
+ sides.base.sort(order);
147
+ sides.cand.sort(order);
148
+ // First the same condition on both sides, then whatever is left of the same check under other conditions.
149
+ const restBase = [];
150
+ for (const result of sides.base) {
151
+ const at = sides.cand.findIndex(other => conditionId(conditionOf(other)) === conditionId(conditionOf(result)));
152
+ if (at < 0)
153
+ restBase.push(result);
154
+ else
155
+ pairs.push(pairOf(key, result, sides.cand.splice(at, 1)[0], []));
156
+ }
157
+ while (restBase.length && sides.cand.length) {
158
+ const result = restBase.shift();
159
+ const other = sides.cand.shift();
160
+ pairs.push(pairOf(key, result, other, conditionDifferences(conditionOf(result), conditionOf(other))));
161
+ }
162
+ for (const result of restBase)
163
+ pairs.push(pairOf(key, result, null, []));
164
+ for (const result of sides.cand)
165
+ pairs.push(pairOf(key, null, result, []));
166
+ }
167
+ pairs.sort((a, b) => COMPARE_GROUPS.indexOf(a.group) - COMPARE_GROUPS.indexOf(b.group) ||
168
+ compareText(a.specPath ?? '', b.specPath ?? '') ||
169
+ compareText(a.title, b.title) ||
170
+ compareText(a.id, b.id));
171
+ const counts = Object.fromEntries(COMPARE_GROUPS.map(group => [group, 0]));
172
+ for (const pair of pairs)
173
+ counts[pair.group] += 1;
174
+ const notes = [];
175
+ if (baseline.run.key === candidate.run.key)
176
+ notes.push('The same run is on both sides, so nothing can differ.');
177
+ if (baseline.run.branch !== candidate.run.branch)
178
+ notes.push(`The runs are on different branches (${baseline.run.branch ?? 'none recorded'} and ${candidate.run.branch ?? 'none recorded'}).`);
179
+ if (candidate.run.endedAtMs < baseline.run.endedAtMs)
180
+ notes.push('The candidate run ended before the baseline run.');
181
+ for (const [name, side] of [['baseline', baseline], ['candidate', candidate]]) {
182
+ if (side.run.freshness === 'modified')
183
+ notes.push(`The ${name} run was recorded from a checkout with uncommitted changes.`);
184
+ if (side.run.counts.running > 0)
185
+ notes.push(`The ${name} run still has results running, so its outcomes may change.`);
186
+ }
187
+ return { baseline: baseline.run, candidate: candidate.run, results: pairs, counts, notes };
188
+ }
189
+ const devicesOf = (entries) => [...new Set(latencyObservations(entries).map(o => groupOf(o, 'device')))].sort();
190
+ /**
191
+ * Compares the product latency recorded in two sets of results of one check, one per side. A metric is compared only
192
+ * when it is the same measurement (name, unit, boundaries) and both sides were measured on the same devices, in the
193
+ * same environments, with the same instrumentation and other conditions; otherwise it is `incompatible` and no change
194
+ * is computed, with the reasons. A metric only one side recorded is named as such. A side without any latency is unknown,
195
+ * not fast: it contributes no metric. `conditionsDiffer` names differences of the results' own conditions, which make
196
+ * every metric incompatible.
197
+ */
198
+ export function compareProductLatency(baseline, candidate, conditionsDiffer = []) {
199
+ const left = latencyReview(baseline).metrics;
200
+ const right = latencyReview(candidate).metrics;
201
+ const sameDevices = devicesOf(baseline).join('\u0001') === devicesOf(candidate).join('\u0001');
202
+ const out = [];
203
+ for (const { metric, groups } of left) {
204
+ const base = groups[0];
205
+ const other = right.find(item => item.metric.key === metric.key)?.groups[0];
206
+ if (!other) {
207
+ const renamed = right.find(item => item.metric.name === metric.name);
208
+ out.push({ state: 'baseline-only', name: metric.name, summary: base, reasons: [renamed ? `The candidate measures ${metric.name} over a different interval (${renamed.metric.boundary.label}), so the two are not the same measurement.` : 'The candidate recorded no such measurement.'] });
209
+ continue;
210
+ }
211
+ const change = compareLatency(base, other);
212
+ const reasons = [...conditionsDiffer.map(line => `Recorded under different conditions: ${line}.`)];
213
+ if (!sameDevices)
214
+ reasons.push(`Devices differ: ${devicesOf(baseline).join(', ')} against ${devicesOf(candidate).join(', ')}.`);
215
+ if (change.comparable) {
216
+ reasons.push(...change.disclosures.filter(line => /^(Environments differ|Test conditions differ|Instrumentation changed)/.test(line)));
217
+ }
218
+ else
219
+ reasons.push(...change.reasons);
220
+ if (reasons.length)
221
+ out.push({ state: 'incompatible', name: metric.name, baseline: base, candidate: other, change: null, reasons });
222
+ else
223
+ out.push({ state: 'compared', name: metric.name, baseline: base, candidate: other, change, reasons: change.disclosures });
224
+ }
225
+ for (const { metric, groups } of right) {
226
+ if (!left.some(item => item.metric.key === metric.key) && !left.some(item => item.metric.name === metric.name)) {
227
+ out.push({ state: 'candidate-only', name: metric.name, summary: groups[0], reasons: ['The baseline recorded no such measurement.'] });
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+ /**
233
+ * The scope that counts the independent executions of one result's check up to the end of its run: the same
234
+ * environment, producer and parameters, and the spec revision that result was recorded against (executions against
235
+ * another revision are other promises). A result that recorded no revision is counted across revisions, which
236
+ * {@link compareReliability} will not compare. Use it for each side, with that side's result and run.
237
+ */
238
+ export function reliabilityScopeAt(result, run) {
239
+ return {
240
+ ...(result.environment ? { environment: [result.environment] } : {}),
241
+ producer: [result.producer],
242
+ ...(result.parameters && Object.keys(result.parameters).length ? { parameter: Object.entries(result.parameters).map(([name, value]) => `${name}=${value}`).sort() } : {}),
243
+ ...(result.specRevision ? { specRevision: [result.specRevision] } : { revision: 'any' }),
244
+ until: run.endedAtMs
245
+ };
246
+ }
247
+ /**
248
+ * Sets the reliability of a check up to the baseline run against its reliability up to the candidate run. A change is
249
+ * stated only when both samples are established (at least `minimumExecutions` independent executions), both counted the
250
+ * same recorded spec revision (see {@link reliabilityScopeAt}) and the conditions match; whether the difference exceeds the uncertainty is
251
+ * said by `direction`. Otherwise each figure stands alone and the reasons say why.
252
+ */
253
+ export function compareReliability(baseline, candidate, conditionsDiffer = []) {
254
+ const reasons = conditionsDiffer.map(line => `Counted under different conditions: ${line}.`);
255
+ for (const [name, document] of [['baseline', baseline], ['candidate', candidate]]) {
256
+ if (document.sample === 'none')
257
+ reasons.push(`The ${name} figure has no completed execution, so it is unknown, not reliable.`);
258
+ else if (document.sample === 'insufficient')
259
+ reasons.push(`The ${name} figure rests on ${document.eventual.executions} independent ${document.eventual.executions === 1 ? 'execution' : 'executions'}; at least ${document.minimumExecutions} are needed.`);
260
+ }
261
+ const revisionOf = (document) => document.scope.specRevision?.join(',') ?? null;
262
+ if (revisionOf(baseline) === null || revisionOf(candidate) === null)
263
+ reasons.push('A side recorded no spec revision, so whether both count the same promise is unknown.');
264
+ else if (revisionOf(baseline) !== revisionOf(candidate))
265
+ reasons.push('The executions were counted against different revisions of the spec, so they describe different promises.');
266
+ const base = baseline.eventual;
267
+ const cand = candidate.eventual;
268
+ if (reasons.length || base.rate === null || cand.rate === null)
269
+ return { comparable: false, baseline: base, candidate: cand, reasons };
270
+ const separate = baseline.interval && candidate.interval ? baseline.interval.upper < candidate.interval.lower || candidate.interval.upper < baseline.interval.lower : false;
271
+ const points = Math.round((cand.rate - base.rate) * 1000) / 10;
272
+ return {
273
+ comparable: true,
274
+ baseline: base,
275
+ candidate: cand,
276
+ points,
277
+ direction: !separate ? 'indistinguishable' : cand.rate > base.rate ? 'higher' : 'lower',
278
+ reasons: separate ? [] : ['The two intervals overlap, so the difference may be chance. It is not shown as an improvement or a decline.']
279
+ };
280
+ }
281
+ /**
282
+ * Lines up the recorded steps of two results by name (the n-th step of a name on one side with the n-th of that name on
283
+ * the other), keeping each side's own time origin: an offset is milliseconds after that side's check began. Nothing is
284
+ * aligned by position or by fuzzy wording. When nothing lines up, `explanation` says why.
285
+ */
286
+ export function alignSteps(baseline, candidate) {
287
+ const keyed = (steps) => {
288
+ const seen = new Map();
289
+ return steps.map(step => {
290
+ const n = seen.get(step.name) ?? 0;
291
+ seen.set(step.name, n + 1);
292
+ return { step, id: `${n}\u0001${step.name}` };
293
+ });
294
+ };
295
+ const left = keyed(baseline);
296
+ const right = keyed(candidate);
297
+ const rightById = new Map(right.map(item => [item.id, item.step]));
298
+ const leftIds = new Set(left.map(item => item.id));
299
+ const steps = [];
300
+ for (const { step, id } of left) {
301
+ const other = rightById.get(id);
302
+ steps.push({
303
+ name: step.name,
304
+ ...(step.offsetMs === undefined ? {} : { baselineOffsetMs: step.offsetMs }),
305
+ ...(other?.offsetMs === undefined ? {} : { candidateOffsetMs: other.offsetMs }),
306
+ onBaseline: true,
307
+ onCandidate: other !== undefined
308
+ });
309
+ }
310
+ for (const { step, id } of right) {
311
+ if (!leftIds.has(id))
312
+ steps.push({ name: step.name, ...(step.offsetMs === undefined ? {} : { candidateOffsetMs: step.offsetMs }), onBaseline: false, onCandidate: true });
313
+ }
314
+ const seekable = steps.filter((step) => step.baselineOffsetMs !== undefined && step.candidateOffsetMs !== undefined);
315
+ const shared = steps.filter(step => step.onBaseline && step.onCandidate).length;
316
+ const differs = steps.some(step => !(step.onBaseline && step.onCandidate));
317
+ let explanation;
318
+ if (seekable.length > 0)
319
+ explanation = `${seekable.length} recorded ${seekable.length === 1 ? 'step is' : 'steps are'} on both sides with a time. Seeking uses each side's own step time from the start of its own check; the recordings are otherwise not synchronized.`;
320
+ else if (!baseline.length && !candidate.length)
321
+ explanation = 'Neither side recorded steps, so the recordings cannot be aligned. Each plays from its own start.';
322
+ else if (!baseline.length || !candidate.length)
323
+ explanation = `Only the ${baseline.length ? 'baseline' : 'candidate'} recorded steps, so the recordings cannot be aligned. Each plays from its own start.`;
324
+ else if (shared === 0)
325
+ explanation = 'The two sides share no step name, so the recordings cannot be aligned. Each plays from its own start.';
326
+ else
327
+ explanation = 'The shared steps carry no time on both sides, so the recordings cannot be aligned. Each plays from its own start.';
328
+ return { state: seekable.length > 0 ? 'aligned' : 'unaligned', steps, seekable, differs, explanation };
329
+ }
@@ -322,6 +322,14 @@ export type RunResult = {
322
322
  readonly detail: string;
323
323
  /** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
324
324
  readonly videos?: readonly VideoRef[];
325
+ /** The kind of the part that held it (`behavior`, `checks`…). With the producer, environment and parameters it is the condition the result was recorded under. Absent from an older server. */
326
+ readonly kind?: string;
327
+ /** The other choices that made it its own result (a device size, a locale…). Absent when there are none, and from an older server. */
328
+ readonly parameters?: Readonly<Record<string, string>>;
329
+ /** The fingerprint of its spec file the layer recorded: results with different ones were recorded against different revisions of the spec. Absent means not recorded, never "the same". */
330
+ readonly specRevision?: string;
331
+ /** The title this run's layer gave the check. `title` is the newest title the check has in the whole history, so a check that was reworded shows its newest wording in both runs; this is the wording as recorded. Absent from an older server. */
332
+ readonly recordedTitle?: string;
325
333
  };
326
334
  /**
327
335
  * One layer of a run, as the run's artifact hub names it: who recorded it, how many checks it holds (zero is possible
@@ -105,6 +105,8 @@ export class TestHistory {
105
105
  #idents = new Map();
106
106
  #runs = new Map();
107
107
  #specs = new RecordedSpecs();
108
+ /** Titles seen, so the many results that share one hold one string. */
109
+ #titles = new Map();
108
110
  #seen = new Set();
109
111
  #rev = 0;
110
112
  #cells = null;
@@ -187,9 +189,16 @@ export class TestHistory {
187
189
  ident.title = row.title;
188
190
  ident.titledAt = layer.recordedAtMs;
189
191
  }
190
- this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements), f: row.specPath ? fingerprints[row.specPath] : undefined });
192
+ this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements), f: row.specPath ? fingerprints[row.specPath] : undefined, ti: this.#title(row.title) });
191
193
  }
192
194
  }
195
+ #title(title) {
196
+ const held = this.#titles.get(title);
197
+ if (held !== undefined)
198
+ return held;
199
+ this.#titles.set(title, title);
200
+ return title;
201
+ }
193
202
  /** Sorted insert, newest first; past the bound, the oldest is dropped. */
194
203
  #remember(slot, entry) {
195
204
  const list = this.#bySlot.get(slot);
@@ -262,6 +271,10 @@ export class TestHistory {
262
271
  key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
263
272
  status: r.s, durationMs: r.d, at: r.o.at, layer: r.o.layer, detail: shortDetail(r.t),
264
273
  ...(r.e?.videos ? { videos: r.e.videos } : {}),
274
+ kind: r.k,
275
+ ...(Object.keys(r.o.parameters).length ? { parameters: r.o.parameters } : {}),
276
+ ...(r.f ? { specRevision: r.f } : {}),
277
+ ...(r.ti !== undefined ? { recordedTitle: r.ti } : {}),
265
278
  }));
266
279
  results.sort((a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || compareText(a.specPath ?? '', b.specPath ?? '') || compareText(a.title, b.title) || compareText(a.environment ?? '', b.environment ?? '') || compareText(a.producer, b.producer) || compareText(a.key, b.key));
267
280
  const layers = [...run.members].sort((a, b) => a.recordedAtMs - b.recordedAtMs || compareText(a.id, b.id));
@@ -2808,6 +2808,8 @@ export declare const CatalogHealthSchema: z.ZodObject<{
2808
2808
  identity: z.ZodObject<{
2809
2809
  checks: z.ZodNumber;
2810
2810
  fallbackIdentity: z.ZodNumber;
2811
+ declaredBehaviorIds: z.ZodOptional<z.ZodNumber>;
2812
+ declaredExampleIds: z.ZodOptional<z.ZodNumber>;
2811
2813
  basis: z.ZodString;
2812
2814
  }, z.core.$loose>;
2813
2815
  domains: z.ZodArray<z.ZodObject<{
@@ -2818,6 +2820,10 @@ export declare const CatalogHealthSchema: z.ZodObject<{
2818
2820
  from: z.ZodString;
2819
2821
  to: z.ZodString;
2820
2822
  matched: z.ZodNumber;
2823
+ source: z.ZodOptional<z.ZodEnum<{
2824
+ request: "request";
2825
+ configuration: "configuration";
2826
+ }>>;
2821
2827
  }, z.core.$loose>>;
2822
2828
  aliases: z.ZodArray<z.ZodObject<{
2823
2829
  path: z.ZodString;
@@ -3624,6 +3630,10 @@ export declare const RunDetailSchema: z.ZodObject<{
3624
3630
  extensions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3625
3631
  }, z.core.$loose>>;
3626
3632
  }, z.core.$loose>>>;
3633
+ kind: z.ZodOptional<z.ZodString>;
3634
+ parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
3635
+ specRevision: z.ZodOptional<z.ZodString>;
3636
+ recordedTitle: z.ZodOptional<z.ZodString>;
3627
3637
  }, z.core.$loose>>;
3628
3638
  layers: z.ZodOptional<z.ZodArray<z.ZodObject<{
3629
3639
  id: z.ZodString;
@@ -70,14 +70,14 @@ export const VideoRefSchema = z.looseObject({
70
70
  step: z.string().optional().describe('The name of the step that holds it'),
71
71
  tags: z.array(z.string()).optional().describe('The normalized tags of this attachment itself. Absent when it has none (and in documents of older servers)'),
72
72
  recording: z.string().optional().describe('What identifies the bytes; references with the same value are one recording. Absent when it cannot be played'),
73
- available: z.boolean().describe('False when the reference names no bytes (no bundle key, address or inline text)'),
73
+ available: z.boolean().describe('False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer\'s own bundle does not hold'),
74
74
  scope: EvidenceScopeSchema.optional().describe('What this recording shows as its producer declared it: its own `scope`, else its attempt\'s, check\'s, part\'s or layer\'s. Absent: the scope is unknown, never live, physical or audible')
75
75
  });
76
76
  /** Video references as counts: how many are referenced, how many distinct recordings can be played, how many cannot. */
77
77
  export const VideoCountsSchema = z.looseObject({
78
78
  references: z.number().int().nonnegative().describe('Every reference to a video; identical bytes referenced twice count twice'),
79
79
  recordings: z.number().int().nonnegative().describe('Distinct playable recordings'),
80
- unavailable: z.number().int().nonnegative().describe('References that name no bytes')
80
+ unavailable: z.number().int().nonnegative().describe('References that name no bytes, or bytes the layer does not hold')
81
81
  });
82
82
  /** What a result carries beyond its outcome, as counts and short facts. Present only when there is something. */
83
83
  export const EvidenceSummarySchema = z.looseObject({
@@ -533,9 +533,15 @@ export const CatalogHealthSchema = z.looseObject({
533
533
  testsWithoutSpec: nonneg,
534
534
  explanation: z.array(z.string())
535
535
  }),
536
- identity: z.looseObject({ checks: nonneg, fallbackIdentity: nonneg.describe('Checks whose id equals their title'), basis: z.string() }),
536
+ identity: z.looseObject({
537
+ checks: nonneg,
538
+ fallbackIdentity: nonneg.describe('Checks whose id equals their title'),
539
+ declaredBehaviorIds: nonneg.optional().describe('Checks that declare a `behavior-id` label'),
540
+ declaredExampleIds: nonneg.optional().describe('Checks that declare an `example-id` label'),
541
+ basis: z.string()
542
+ }),
537
543
  domains: z.array(z.looseObject({ name: z.string(), specs: nonneg })).describe('Specs per domain, most first'),
538
- roots: z.array(z.looseObject({ from: z.string(), to: z.string(), matched: nonneg.describe('Distinct paths the mapping changed') })),
544
+ roots: z.array(z.looseObject({ from: z.string(), to: z.string(), matched: nonneg.describe('Distinct paths the mapping changed'), source: z.enum(['configuration', 'request']).optional().describe('Where it was declared: the server\'s `catalog.roots` setting or this request\'s `root` parameter') })),
539
545
  aliases: z.array(z.looseObject({ path: z.string(), sources: z.array(z.string()) })).describe('Logical specs seen under more than one original path, joined by a root mapping'),
540
546
  findings: z.array(z.looseObject({
541
547
  code: z.string().describe('An open set: treat a code you do not know as informational'),
@@ -671,7 +677,11 @@ export const RunDetailSchema = z.looseObject({
671
677
  results: z.array(z.looseObject({
672
678
  key: z.string(), id: z.string(), title: z.string(), specPath: z.string().nullable(), environment: z.string().nullable(), producer: z.string(),
673
679
  status: EvidenceStatusSchema, durationMs: z.number().nullable(), at: z.number(), layer: z.string(), detail: z.string(),
674
- videos: z.array(VideoRefSchema).optional().describe('Every video its evidence references, with who holds it')
680
+ videos: z.array(VideoRefSchema).optional().describe('Every video its evidence references, with who holds it'),
681
+ kind: z.string().optional().describe('The kind of the part that held it. With the producer, environment and parameters it is the condition the result was recorded under. Absent from an older server'),
682
+ parameters: z.record(z.string(), z.string()).optional().describe('The other choices that made it its own result (a device size, a locale…). Absent when there are none, and from an older server'),
683
+ specRevision: z.string().optional().describe('The fingerprint of its spec file the layer recorded. Absent means not recorded, never "the same"'),
684
+ recordedTitle: z.string().optional().describe('The title this run\'s layer gave the check. `title` is the check\'s newest title in the whole history; this is the wording as recorded. Absent from an older server')
675
685
  })),
676
686
  layers: z.array(z.looseObject({
677
687
  id: z.string().describe('The layer id; read its files with `/api/reports/evidence/detail/{backend}/{id}`'),
@@ -66,6 +66,9 @@ export declare const ServerConfigSchema: z.ZodObject<{
66
66
  branches: z.ZodOptional<z.ZodArray<z.ZodString>>;
67
67
  ignoreModified: z.ZodOptional<z.ZodBoolean>;
68
68
  }, z.core.$strict>>;
69
+ catalog: z.ZodDefault<z.ZodObject<{
70
+ roots: z.ZodDefault<z.ZodArray<z.ZodString>>;
71
+ }, z.core.$strict>>;
69
72
  refreshSeconds: z.ZodDefault<z.ZodNumber>;
70
73
  limits: z.ZodDefault<z.ZodObject<{
71
74
  maxObjectMiB: z.ZodDefault<z.ZodNumber>;
@@ -50,6 +50,11 @@ export const ServerConfigSchema = z.strictObject({
50
50
  ignoreModified: z.boolean().optional().describe('Leave out layers from modified checkouts and unknown origins')
51
51
  })
52
52
  .default({}),
53
+ catalog: z
54
+ .strictObject({
55
+ roots: z.array(z.string().refine((value) => value.includes('>'), { message: 'Write a root mapping as from>to, e.g. ">specs/" (paths under `from` are the same specs as paths under `to`)' })).default([]).describe('Root mappings `from>to` that relate spec paths a producer publishes to the paths the specs are recorded under (an empty `from` matches every path). The catalog health report applies them to every request, beside any `root` the request adds')
56
+ })
57
+ .default({ roots: [] }),
53
58
  refreshSeconds: z.number().min(1).default(10).describe('How often the backends are looked at for layers written by something other than this server'),
54
59
  limits: z
55
60
  .strictObject({
@@ -30,6 +30,8 @@ export type AtlasServerOptions = {
30
30
  /** The built viewer. Without it, only the API is served. */
31
31
  readonly site?: string;
32
32
  readonly outlineBranch?: string;
33
+ /** Root mappings `from>to` the catalog health report always applies (the `catalog.roots` setting), beside any `root` a request adds. */
34
+ readonly catalogRoots?: readonly string[];
33
35
  readonly include?: (layer: LayerHeader) => boolean;
34
36
  readonly refreshMs?: number;
35
37
  readonly limits?: {
@@ -26,7 +26,7 @@ import { reportStatus } from '../../reports/src/report-status.js';
26
26
  import { ReportIndex } from '../../reports/src/report-index.js';
27
27
  import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
28
28
  import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
29
- import { catalogHealth, healthMarkdown, parseRootMappings } from '../../reports/src/catalog-health.js';
29
+ import { catalogHealth, healthMarkdown, parseRootMapping, parseRootMappings } from '../../reports/src/catalog-health.js';
30
30
  import { parseArtifactFilter } from '../../reports/src/artifact-query.js';
31
31
  import { filterUiCatalog, pageUiCatalog, parseUiCatalogQuery, uiCatalog, uiCatalogMarkdown } from '../../reports/src/ui-catalog.js';
32
32
  import { parseReliabilityScope, reliability, reliabilityMarkdown } from '../../reports/src/reliability.js';
@@ -451,12 +451,16 @@ function createProjectApplication(options) {
451
451
  const document = () => testCatalog(found, query);
452
452
  return answer(c, found.version, document, () => catalogMarkdown(document()), `tests|${new URL(c.req.url).search}|${wantsMarkdown(c)}`);
453
453
  });
454
+ const configuredRoots = (options.catalogRoots ?? []).flatMap(text => parseRootMapping(text) ?? []).map(mapping => ({ ...mapping, source: 'configuration' }));
454
455
  app.get('/api/reports/tests/health', async (c) => {
455
456
  if (!index.ready)
456
457
  return notReady(c);
457
458
  const found = await catalog();
458
459
  const params = new URL(c.req.url).searchParams;
459
- const document = () => catalogHealth(found, parseRootMappings(params));
460
+ // The mappings the operator configured, then those this request adds (one already configured is not counted twice).
461
+ const requested = parseRootMappings(params).filter(mapping => !configuredRoots.some(item => item.from === mapping.from && item.to === mapping.to));
462
+ const roots = [...configuredRoots, ...requested.map(mapping => ({ ...mapping, source: 'request' }))];
463
+ const document = () => catalogHealth(found, roots);
460
464
  return answer(c, found.version, document, () => healthMarkdown(document()), `health|${params.getAll('root').join('\u0001')}|${wantsMarkdown(c)}`);
461
465
  });
462
466
  app.get('/api/reports/tests/history', async (c) => {
@@ -57,6 +57,7 @@ export const SERVER_SETTINGS = [
57
57
  { summary: 'Whose specs the report is laid over', key: 'outlineBranch', flag: '--outline-branch <branch>', env: ['ATLAS_OUTLINE_BRANCH'], default: '`main`, then `master`, then the newest', example: { value: 'trunk', read: c => c.outlineBranch, expected: 'trunk' } },
58
58
  { summary: 'Only layers recorded on these branches take part', key: 'include.branches', flag: '--branch <name>` (repeatable)', env: ['ATLAS_ONLY_BRANCH'], default: 'all', example: { value: 'main', read: c => c.include.branches, expected: ['main'] } },
59
59
  { summary: 'Leave out layers from modified checkouts and unknown origins', key: 'include.ignoreModified', flag: '--ignore-modified', env: ['ATLAS_IGNORE_MODIFIED'], default: 'no', example: { value: '1', read: c => c.include.ignoreModified, expected: true } },
60
+ { summary: 'Root mappings `from>to` that join spec paths a producer publishes to the paths the specs are recorded under, for the catalog health report (comma-separated; an empty `from` matches every path)', key: 'catalog.roots', flag: null, env: ['ATLAS_CATALOG_ROOTS'], default: 'none', example: { value: '>specs/,apps/>specs/web/', read: c => c.catalog.roots, expected: ['>specs/', 'apps/>specs/web/'] } },
60
61
  { summary: 'How often (seconds) the backends are looked at for layers written by something else', key: 'refreshSeconds', flag: '--refresh <seconds>', env: ['ATLAS_REFRESH_SECONDS'], default: '`10`', example: { value: '30', read: c => c.refreshSeconds, expected: 30 } },
61
62
  { summary: 'Let anyone look, not upload', key: 'auth.anonymousRead', flag: '--anonymous-read', env: ['ATLAS_ANONYMOUS_READ'], default: 'no', example: { value: '1', read: c => c.anonymousRead, expected: true } },
62
63
  { summary: 'Listen publicly with no tokens (something in front signs people in)', key: 'auth.insecure', flag: '--insecure', env: ['ATLAS_INSECURE'], default: 'no', example: { value: '1', read: c => c.insecure, expected: true } },
@@ -151,6 +152,9 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
151
152
  limits[key] = Number(env[variable]);
152
153
  }
153
154
  merged.limits = limits;
155
+ const roots = list(env.ATLAS_CATALOG_ROOTS);
156
+ if (roots.length)
157
+ merged.catalog = { ...raw.catalog, roots };
154
158
  if (flags.insecure || truthy(env.ATLAS_INSECURE))
155
159
  auth.insecure = true;
156
160
  if (flags['anonymous-read'] || truthy(env.ATLAS_ANONYMOUS_READ))
@@ -56,6 +56,7 @@ export function serverOptionsOf(config, backends, log) {
56
56
  backends, write: S3_BACKEND_ID,
57
57
  tokens: config.tokens, anonymousRead: config.anonymousRead,
58
58
  ...(config.outlineBranch ? { outlineBranch: config.outlineBranch } : {}), ...(include ? { include } : {}),
59
+ ...(config.catalog.roots.length ? { catalogRoots: config.catalog.roots } : {}),
59
60
  runTimeoutMs: config.runTimeoutSeconds * 1000, runStallMs: config.runStallSeconds * 1000, refreshMs: config.refreshSeconds * 1000, limits: config.limits, heartbeatMs: config.heartbeatSeconds * 1000,
60
61
  ...(config.log && log ? { log } : {})
61
62
  };
@@ -313,10 +313,10 @@ const BASE_OPERATIONS = [
313
313
  '',
314
314
  '`counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.',
315
315
  '',
316
- 'Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check\'s label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.'
316
+ 'Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check\'s label `outline` names the outline its example belongs to; the labels `behavior-id` and `example-id` declare stable ids, which are counted and checked for ids shared by several checks. Send `format=markdown` for Markdown.'
317
317
  ].join('\n'),
318
318
  parameters: [
319
- { name: 'root', in: 'query', description: 'A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.', schema: str, example: '>specs/' },
319
+ { name: 'root', in: 'query', description: 'A root mapping `from>to`, URL-encoded. Repeatable or comma-separated. It is added to the mappings the server is configured with (`catalog.roots`, `ATLAS_CATALOG_ROOTS`); `roots[].source` says which is which.', schema: str, example: '>specs/' },
320
320
  { name: 'format', in: 'query', description: '`markdown` for a Markdown report instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
321
321
  ],
322
322
  responses: { '200': { ...json('The diagnostics.', 'CatalogHealth'), headers: { ETag: { description: 'Changes with the report, its history and the mappings.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
package/docs/extending.md CHANGED
@@ -30,7 +30,7 @@ carry any of these. Every one is optional.
30
30
  | `annotations[]` | Notes about the result: a warning, a deprecation, an accessibility finding, a coverage figure. |
31
31
  | `attempts[]` | Every try, when the check was retried. |
32
32
  | `tags[]` | Free labels to filter by: `smoke`, `payments`. |
33
- | `labels{}` | Facts about the result that are not its identity: `owner`, `severity`, `browser`, `build`. The viewer reads `build`, `platform`, `applies-to` and `applies-to-by` to tell whether evidence applies to a release (see [the viewer conventions](ui.md#behaviors-read-watch-inspect)). |
33
+ | `labels{}` | Facts about the result that are not its identity: `owner`, `severity`, `browser`, `build`. The viewer reads `build`, `platform`, `applies-to` and `applies-to-by` to tell whether evidence applies to a release (see [the viewer conventions](ui.md#behaviors-read-watch-inspect)). `behavior-id` and `example-id` declare a stable id for the behavior and for one example of an outline, and `outline` names the outline; the [catalog health](server.md#reading-the-report-as-an-agent) report counts them and names an id shared by several checks. They never change which check a result belongs to. |
34
34
  | `scope{}` | What the evidence does and does not show: surface, device kind, synthetic data, scripted dependencies, audio, pacing, what was asserted and its limits. Unknown when absent. See [Evidence scope](rich-evidence.md#evidence-scope). |
35
35
  | `links[]` | Where to read more: the CI run, the issue, the trace client. |
36
36
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |
@@ -550,9 +550,9 @@ Diagnoses the report's metadata, so a domain filter or a coverage percentage can
550
550
 
551
551
  `counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
552
552
 
553
- Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
553
+ Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to; the labels `behavior-id` and `example-id` declare stable ids, which are counted and checked for ids shared by several checks. Send `format=markdown` for Markdown.
554
554
 
555
- - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
555
+ - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated. It is added to the mappings the server is configured with (`catalog.roots`, `ATLAS_CATALOG_ROOTS`); `roots[].source` says which is which.
556
556
  - `format` (query): `markdown` for a Markdown report instead of JSON.
557
557
  - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
558
558
 
@@ -566,12 +566,12 @@ Diagnoses the report's metadata, so a domain filter or a coverage percentage can
566
566
 
567
567
  `counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
568
568
 
569
- Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
569
+ Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to; the labels `behavior-id` and `example-id` declare stable ids, which are counted and checked for ids shared by several checks. Send `format=markdown` for Markdown.
570
570
 
571
571
  Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
572
572
 
573
573
  - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
574
- - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
574
+ - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated. It is added to the mappings the server is configured with (`catalog.roots`, `ATLAS_CATALOG_ROOTS`); `roots[].source` says which is which.
575
575
  - `format` (query): `markdown` for a Markdown report instead of JSON.
576
576
 
577
577
  Responses: `200` The diagnostics.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.