@crvouga/atlas 1.15.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.15.0 (2026-10-29)
3
+ ## 1.16.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - explain catalog counts and identity in a Catalog health tab with persistent root mappings (#71) ([0974e7a](https://github.com/crvouga/atlas/commit/0974e7a8d16b126c47e79a004974a629275122ad))
8
+
9
+ ## 1.15.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
@@ -3,10 +3,18 @@ import type { CatalogModel } from './test-catalog.js';
3
3
  export declare const CATALOG_HEALTH_VERSION = 1;
4
4
  /** The most paths listed in one finding; its `count` is always the whole. */
5
5
  export declare const HEALTH_ITEM_LIMIT = 25;
6
- /** Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from` matches every path. */
6
+ /** The label a producer puts on a check to give its behavior (the promise) an id that outlives renames and moves. */
7
+ export declare const BEHAVIOR_ID_LABEL = "behavior-id";
8
+ /** The label a producer puts on a check to give one example of a scenario outline an id of its own. */
9
+ export declare const EXAMPLE_ID_LABEL = "example-id";
10
+ /**
11
+ * Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from`
12
+ * matches every path. `source` says where it was declared (the server's configuration or the request); it changes nothing.
13
+ */
7
14
  export type RootMapping = {
8
15
  readonly from: string;
9
16
  readonly to: string;
17
+ readonly source?: 'configuration' | 'request';
10
18
  };
11
19
  /** One thing worth a look. */
12
20
  export type HealthItem = {
@@ -18,7 +26,7 @@ export type HealthItem = {
18
26
  }[];
19
27
  };
20
28
  /** Codes of `HealthFinding.code`. The set grows; a reader treats an unknown code as informational. */
21
- export type HealthCode = 'ambiguous-spec-path' | 'unmatched-spec-path' | 'duplicate-spec-content' | 'duplicate-spec-title' | 'generic-domain' | 'tests-without-spec' | 'no-spec-definitions' | 'fallback-identity' | 'unused-root-mapping' | 'spec-without-execution' | 'historical-only';
29
+ export type HealthCode = 'ambiguous-spec-path' | 'unmatched-spec-path' | 'duplicate-spec-content' | 'duplicate-spec-title' | 'generic-domain' | 'tests-without-spec' | 'no-spec-definitions' | 'fallback-identity' | 'unused-root-mapping' | 'declared-id-split' | 'spec-without-execution' | 'historical-only';
22
30
  /** A finding: what is inconsistent, how many it affects, and what to change. */
23
31
  export type HealthFinding = {
24
32
  readonly code: HealthCode;
@@ -100,6 +108,10 @@ export type CatalogHealth = {
100
108
  readonly checks: number;
101
109
  /** Checks whose id equals their title: no stable id was given. */
102
110
  readonly fallbackIdentity: number;
111
+ /** Checks that declare a `behavior-id` label. Optional: absent from documents written before it existed. */
112
+ readonly declaredBehaviorIds?: number;
113
+ /** Checks that declare an `example-id` label. */
114
+ readonly declaredExampleIds?: number;
103
115
  readonly basis: string;
104
116
  };
105
117
  readonly domains: readonly {
@@ -110,6 +122,7 @@ export type CatalogHealth = {
110
122
  readonly from: string;
111
123
  readonly to: string;
112
124
  readonly matched: number;
125
+ readonly source?: 'configuration' | 'request';
113
126
  }[];
114
127
  readonly aliases: readonly SpecAlias[];
115
128
  readonly findings: readonly HealthFinding[];
@@ -7,6 +7,10 @@ export const HEALTH_ITEM_LIMIT = 25;
7
7
  const CANDIDATE_LIMIT = 5;
8
8
  /** Folder names that say where a spec lives, not what it is about. A domain with one of these names is likely a root folder. */
9
9
  const GENERIC_DOMAINS = new Set(['specs', 'spec', 'product', 'features', 'feature', 'tests', 'test', 'e2e', 'src', 'app', 'docs', 'overview', 'root', 'default']);
10
+ /** The label a producer puts on a check to give its behavior (the promise) an id that outlives renames and moves. */
11
+ export const BEHAVIOR_ID_LABEL = 'behavior-id';
12
+ /** The label a producer puts on a check to give one example of a scenario outline an id of its own. */
13
+ export const EXAMPLE_ID_LABEL = 'example-id';
10
14
  /** Parses `from>to` (as `?root=` gives it) into a mapping. Returns null when there is no `>`. Whitespace around each side is trimmed. */
11
15
  export function parseRootMapping(text) {
12
16
  const at = text.indexOf('>');
@@ -164,6 +168,21 @@ export function catalogHealth(model, roots = []) {
164
168
  const fallback = model.tests.filter(test => test.id === test.title);
165
169
  if (fallback.length)
166
170
  findings.push(finding('fallback-identity', 'info', 'These checks use their title as their id. A check is its spec path and id, so renaming one starts a new check and splits its history. Have the producer send a stable `id`.', fallback.map(test => ({ path: test.id })), fallback.length));
171
+ const split = [];
172
+ for (const label of [BEHAVIOR_ID_LABEL, EXAMPLE_ID_LABEL]) {
173
+ const byId = new Map();
174
+ for (const test of model.tests) {
175
+ const id = test.labels[label];
176
+ if (id)
177
+ byId.set(id, [...(byId.get(id) ?? []), test]);
178
+ }
179
+ for (const [id, tests] of [...byId].sort(([a], [b]) => byText(a, b))) {
180
+ if (tests.length > 1)
181
+ split.push({ path: `${label}=${id}`, candidates: tests.map(test => test.specPath ? `${test.specPath} ${test.id}` : test.id).sort(byText).slice(0, CANDIDATE_LIMIT).map(path => ({ path, reason: `same ${label}` })) });
182
+ }
183
+ }
184
+ if (split.length)
185
+ findings.push(finding('declared-id-split', 'info', 'These declared ids sit on more than one check: the promise was renamed or moved, or two checks claim it. Atlas keeps the checks apart (a check is its spec path and id), so their histories are separate.', split));
167
186
  const unused = ordered.filter(mapping => !matched.get(mapping)?.size);
168
187
  if (unused.length)
169
188
  findings.push(finding('unused-root-mapping', 'warning', 'These root mappings matched no spec path. Check `from` against the paths in the report.', unused.map(mapping => ({ path: `${mapping.from}>${mapping.to}` }))));
@@ -213,10 +232,12 @@ export function catalogHealth(model, roots = []) {
213
232
  identity: {
214
233
  checks: model.tests.length,
215
234
  fallbackIdentity: fallback.length,
216
- basis: 'A check is its spec path and its id. A check whose producer sent no id is known by its title, so a rename is a new check; a spec is known by its path, joined to another only by a declared root mapping.'
235
+ declaredBehaviorIds: model.tests.filter(test => test.labels[BEHAVIOR_ID_LABEL]).length,
236
+ declaredExampleIds: model.tests.filter(test => test.labels[EXAMPLE_ID_LABEL]).length,
237
+ basis: 'A check is its spec path and its id. A check whose producer sent no id is known by its title, so a rename is a new check; a spec is known by its path, joined to another only by a declared root mapping. The labels behavior-id and example-id are counted and checked for ids shared by several checks; they do not change which check a result belongs to.'
217
238
  },
218
239
  domains,
219
- roots: ordered.map(mapping => ({ from: mapping.from, to: mapping.to, matched: matched.get(mapping)?.size ?? 0 })),
240
+ roots: ordered.map(mapping => ({ from: mapping.from, to: mapping.to, matched: matched.get(mapping)?.size ?? 0, ...(mapping.source ? { source: mapping.source } : {}) })),
220
241
  aliases,
221
242
  findings
222
243
  };
@@ -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;
@@ -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'),
@@ -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`.
@@ -806,6 +806,10 @@ Explains why the Specs and the Tests of a report may disagree, and what a produc
806
806
  - **Fallback identity.** A check whose id is its title has no stable id; renaming it starts a new check. Counted, not
807
807
  guessed at.
808
808
 
809
+ - **Declared ids.** A producer may say what a check is with the labels `behavior-id` (the promise) and `example-id`
810
+ (one example of an outline). Atlas counts them and names ids that sit on more than one check; it does not join the
811
+ checks, because a check is still its spec path and id.
812
+
809
813
  It only reads: it never changes a status, a slot or a winner, so it cannot disagree with the report it describes.
810
814
  Optional label `outline` on a check names the scenario outline its example belongs to.
811
815
 
@@ -827,13 +831,30 @@ export const HEALTH_ITEM_LIMIT = …
827
831
 
828
832
  The most paths listed in one finding; its `count` is always the whole.
829
833
 
834
+ #### `BEHAVIOR_ID_LABEL`
835
+
836
+ ```ts
837
+ export const BEHAVIOR_ID_LABEL = …
838
+ ```
839
+
840
+ The label a producer puts on a check to give its behavior (the promise) an id that outlives renames and moves.
841
+
842
+ #### `EXAMPLE_ID_LABEL`
843
+
844
+ ```ts
845
+ export const EXAMPLE_ID_LABEL = …
846
+ ```
847
+
848
+ The label a producer puts on a check to give one example of a scenario outline an id of its own.
849
+
830
850
  #### `RootMapping`
831
851
 
832
852
  ```ts
833
- export type RootMapping = { readonly from: string; readonly to: string };
853
+ export type RootMapping = { readonly from: string; readonly to: string; readonly source?: 'configuration' | 'request' };
834
854
  ```
835
855
 
836
- Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from` matches every path.
856
+ Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from`
857
+ matches every path. `source` says where it was declared (the server's configuration or the request); it changes nothing.
837
858
 
838
859
  #### `HealthItem`
839
860
 
@@ -860,6 +881,7 @@ export type HealthCode =
860
881
  | 'no-spec-definitions'
861
882
  | 'fallback-identity'
862
883
  | 'unused-root-mapping'
884
+ | 'declared-id-split'
863
885
  | 'spec-without-execution'
864
886
  | 'historical-only';
865
887
  ```
@@ -31,6 +31,8 @@ export type AtlasServerOptions = {
31
31
  /** The built viewer. Without it, only the API is served. */
32
32
  readonly site?: string;
33
33
  readonly outlineBranch?: string;
34
+ /** Root mappings `from>to` the catalog health report always applies (the `catalog.roots` setting), beside any `root` a request adds. */
35
+ readonly catalogRoots?: readonly string[];
34
36
  readonly include?: (layer: LayerHeader) => boolean;
35
37
  readonly refreshMs?: number;
36
38
  readonly limits?: { readonly maxObjectMiB?: number; readonly maxJsonMiB?: number; readonly maxStreams?: number };
package/docs/server.md CHANGED
@@ -202,6 +202,7 @@ is said in words and the server does not start. The file has a published
202
202
  | Whose specs the report is laid over | `outlineBranch` | `--outline-branch <branch>` | `ATLAS_OUTLINE_BRANCH` | `main`, then `master`, then the newest |
203
203
  | Only layers recorded on these branches take part | `include.branches` | `--branch <name>` (repeatable) | `ATLAS_ONLY_BRANCH` | all |
204
204
  | Leave out layers from modified checkouts and unknown origins | `include.ignoreModified` | `--ignore-modified` | `ATLAS_IGNORE_MODIFIED` | no |
205
+ | 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) | `catalog.roots` | | `ATLAS_CATALOG_ROOTS` | none |
205
206
  | How often (seconds) the backends are looked at for layers written by something else | `refreshSeconds` | `--refresh <seconds>` | `ATLAS_REFRESH_SECONDS` | `10` |
206
207
  | Let anyone look, not upload | `auth.anonymousRead` | `--anonymous-read` | `ATLAS_ANONYMOUS_READ` | no |
207
208
  | Listen publicly with no tokens (something in front signs people in) | `auth.insecure` | `--insecure` | `ATLAS_INSECURE` | no |
@@ -442,8 +443,16 @@ curl "$ATLAS_SERVER_URL/api/projects/$ATLAS_PROJECT/reports/tests?format=markdow
442
443
  the Tests total. Two spec paths are one logical spec only when a mapping says so
443
444
  (`?root=<from>><to>`, for example `?root=%3Especs/` to put `checkout/cart.feature` under `specs/`); similar paths
444
445
  are reported as ambiguous and never merged, and a similar title is never an identity. Give a check the label
445
- `outline` to say which scenario outline an example belongs to. A domain shared by nearly every spec is flagged, but
446
- explicit domain metadata always stays authoritative. The document only reads: it changes no status.
446
+ `outline` to say which scenario outline an example belongs to, and `behavior-id` / `example-id` to give a behavior
447
+ or one example a stable id of your own: Atlas counts them and names an id that sits on more than one check (a
448
+ rename or a move), but a check is still its spec path and id, so nothing is joined by them. A domain shared by nearly
449
+ every spec is flagged, but explicit domain metadata always stays authoritative. The document only reads: it changes
450
+ no status.
451
+ - Mappings that should always apply are a setting, not a query parameter: `catalog.roots` in `atlas.server.json` (a list
452
+ of `from>to`) or `ATLAS_CATALOG_ROOTS` (comma-separated; it replaces the file's list). The health document applies
453
+ them, then adds the request's `?root=`, and says in `roots[].source` which is which. A mapping with no `>` stops the
454
+ server starting. The viewer's **Catalog health** tab shows the same document, and its mapping form (the `root`
455
+ address parameter) tries further mappings without changing the server.
447
456
  - The server keeps the newest 100 results of every test (progress reported as `running` is not history) and every run's last result per test, in memory, as layers
448
457
  arrive; it is rebuilt from the layers on restart. History never changes a result.
449
458
  - Without a published spec set, tests are grouped under the specs their layers recorded (`specs[]`: path, domain,
package/docs/ui.md CHANGED
@@ -96,6 +96,25 @@ when Watch is on screen.
96
96
  - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
97
97
  none are listed under "No domain declared"; the Tests view reports domain problems).
98
98
 
99
+ ## Catalog health
100
+
101
+ The **Catalog health** tab (`explorer/HealthView.tsx`, `?tab=health`; `@crvouga/atlas/reports/catalog-health`) explains why the Specs
102
+ and the Tests disagree. The Specs list links to it ("Why do these counts differ from the Tests?"). On a server it reads
103
+ `GET /api/reports/tests/health`; elsewhere the same document is worked out in the page from the specs and results it holds, and an
104
+ older server without the route is told so in words.
105
+
106
+ - Denominators are never one number: spec files, defined specs, specs with tests, specs executed and defined but not executed
107
+ (the coverage gaps, which add to no passed count), tests, outlines, examples and observations (an outline, its examples and
108
+ their per-environment results are told apart). Each figure has its meaning in its `title`.
109
+ - A table walks from the Specs total to the Tests total, step by step, with the sentences below it. Findings are an `Accordion`
110
+ (warnings open), each with its severity as text, its code, how many it affects and the paths. Paths that might be one promise
111
+ are shown as "may be the same promise as ...; not merged": a similar title never joins two specs.
112
+ - Root mappings (`from>to`) join spec paths. Those the server is configured with (`catalog.roots`, `ATLAS_CATALOG_ROOTS`) are
113
+ labelled "Configured on the server"; others are tried in the form and kept in the address (`root`), labelled "Tried here", and
114
+ change nothing on the server. A mapping that matched no path is a warning. Joined specs list every original path.
115
+ - Domains are listed as declared; a domain shared by nearly every spec is a finding, never rewritten. Identity states how many
116
+ checks are known only by title, how many declare `behavior-id` / `example-id`, and the limits of both.
117
+
99
118
  ## UI catalog
100
119
 
101
120
  `/catalog` (`catalog/`) is the viewer's inventory of captured UI states ([the guide](ui-catalog.md)). It is computed in the page from
@@ -320,7 +339,7 @@ The narrowly scoped exceptions are:
320
339
  | --- | --- | --- |
321
340
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
322
341
  | UI catalog | Captured UI states per profile as cards with coverage badges, filters in the index column, surface coverage, an open state with its profiles side by side and where each picture came from, manifest download | AppShell navbar, SimpleGrid, Card, Image, MultiSelect, TextInput, Drawer, Accordion, Table, Badge, Alert |
323
- | Project home | Tests/runs/artifacts/compare tabs, a two-run comparison with promise, outcome, recording and figure tabs per behavior, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
342
+ | Project home | Tests/runs/artifacts/compare/catalog health tabs, a two-run comparison with promise, outcome, recording and figure tabs per behavior, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
324
343
  | Test detail | Current outcomes, statistics, duration graph, product latency with budgets and baselines, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
325
344
  | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
326
345
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
package/llms-full.txt CHANGED
@@ -1850,6 +1850,7 @@ is said in words and the server does not start. The file has a published
1850
1850
  | Whose specs the report is laid over | `outlineBranch` | `--outline-branch <branch>` | `ATLAS_OUTLINE_BRANCH` | `main`, then `master`, then the newest |
1851
1851
  | Only layers recorded on these branches take part | `include.branches` | `--branch <name>` (repeatable) | `ATLAS_ONLY_BRANCH` | all |
1852
1852
  | Leave out layers from modified checkouts and unknown origins | `include.ignoreModified` | `--ignore-modified` | `ATLAS_IGNORE_MODIFIED` | no |
1853
+ | 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) | `catalog.roots` | | `ATLAS_CATALOG_ROOTS` | none |
1853
1854
  | How often (seconds) the backends are looked at for layers written by something else | `refreshSeconds` | `--refresh <seconds>` | `ATLAS_REFRESH_SECONDS` | `10` |
1854
1855
  | Let anyone look, not upload | `auth.anonymousRead` | `--anonymous-read` | `ATLAS_ANONYMOUS_READ` | no |
1855
1856
  | Listen publicly with no tokens (something in front signs people in) | `auth.insecure` | `--insecure` | `ATLAS_INSECURE` | no |
@@ -2090,8 +2091,16 @@ curl "$ATLAS_SERVER_URL/api/projects/$ATLAS_PROJECT/reports/tests?format=markdow
2090
2091
  the Tests total. Two spec paths are one logical spec only when a mapping says so
2091
2092
  (`?root=<from>><to>`, for example `?root=%3Especs/` to put `checkout/cart.feature` under `specs/`); similar paths
2092
2093
  are reported as ambiguous and never merged, and a similar title is never an identity. Give a check the label
2093
- `outline` to say which scenario outline an example belongs to. A domain shared by nearly every spec is flagged, but
2094
- explicit domain metadata always stays authoritative. The document only reads: it changes no status.
2094
+ `outline` to say which scenario outline an example belongs to, and `behavior-id` / `example-id` to give a behavior
2095
+ or one example a stable id of your own: Atlas counts them and names an id that sits on more than one check (a
2096
+ rename or a move), but a check is still its spec path and id, so nothing is joined by them. A domain shared by nearly
2097
+ every spec is flagged, but explicit domain metadata always stays authoritative. The document only reads: it changes
2098
+ no status.
2099
+ - Mappings that should always apply are a setting, not a query parameter: `catalog.roots` in `atlas.server.json` (a list
2100
+ of `from>to`) or `ATLAS_CATALOG_ROOTS` (comma-separated; it replaces the file's list). The health document applies
2101
+ them, then adds the request's `?root=`, and says in `roots[].source` which is which. A mapping with no `>` stops the
2102
+ server starting. The viewer's **Catalog health** tab shows the same document, and its mapping form (the `root`
2103
+ address parameter) tries further mappings without changing the server.
2095
2104
  - The server keeps the newest 100 results of every test (progress reported as `running` is not history) and every run's last result per test, in memory, as layers
2096
2105
  arrive; it is rebuilt from the layers on restart. History never changes a result.
2097
2106
  - Without a published spec set, tests are grouped under the specs their layers recorded (`specs[]`: path, domain,
@@ -3195,7 +3204,7 @@ carry any of these. Every one is optional.
3195
3204
  | `annotations[]` | Notes about the result: a warning, a deprecation, an accessibility finding, a coverage figure. |
3196
3205
  | `attempts[]` | Every try, when the check was retried. |
3197
3206
  | `tags[]` | Free labels to filter by: `smoke`, `payments`. |
3198
- | `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)). |
3207
+ | `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. |
3199
3208
  | `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). |
3200
3209
  | `links[]` | Where to read more: the CI run, the issue, the trace client. |
3201
3210
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |
@@ -4515,6 +4524,25 @@ when Watch is on screen.
4515
4524
  - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
4516
4525
  none are listed under "No domain declared"; the Tests view reports domain problems).
4517
4526
 
4527
+ ## Catalog health
4528
+
4529
+ The **Catalog health** tab (`explorer/HealthView.tsx`, `?tab=health`; `@crvouga/atlas/reports/catalog-health`) explains why the Specs
4530
+ and the Tests disagree. The Specs list links to it ("Why do these counts differ from the Tests?"). On a server it reads
4531
+ `GET /api/reports/tests/health`; elsewhere the same document is worked out in the page from the specs and results it holds, and an
4532
+ older server without the route is told so in words.
4533
+
4534
+ - Denominators are never one number: spec files, defined specs, specs with tests, specs executed and defined but not executed
4535
+ (the coverage gaps, which add to no passed count), tests, outlines, examples and observations (an outline, its examples and
4536
+ their per-environment results are told apart). Each figure has its meaning in its `title`.
4537
+ - A table walks from the Specs total to the Tests total, step by step, with the sentences below it. Findings are an `Accordion`
4538
+ (warnings open), each with its severity as text, its code, how many it affects and the paths. Paths that might be one promise
4539
+ are shown as "may be the same promise as ...; not merged": a similar title never joins two specs.
4540
+ - Root mappings (`from>to`) join spec paths. Those the server is configured with (`catalog.roots`, `ATLAS_CATALOG_ROOTS`) are
4541
+ labelled "Configured on the server"; others are tried in the form and kept in the address (`root`), labelled "Tried here", and
4542
+ change nothing on the server. A mapping that matched no path is a warning. Joined specs list every original path.
4543
+ - Domains are listed as declared; a domain shared by nearly every spec is a finding, never rewritten. Identity states how many
4544
+ checks are known only by title, how many declare `behavior-id` / `example-id`, and the limits of both.
4545
+
4518
4546
  ## UI catalog
4519
4547
 
4520
4548
  `/catalog` (`catalog/`) is the viewer's inventory of captured UI states ([the guide](ui-catalog.md)). It is computed in the page from
@@ -4739,7 +4767,7 @@ The narrowly scoped exceptions are:
4739
4767
  | --- | --- | --- |
4740
4768
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
4741
4769
  | UI catalog | Captured UI states per profile as cards with coverage badges, filters in the index column, surface coverage, an open state with its profiles side by side and where each picture came from, manifest download | AppShell navbar, SimpleGrid, Card, Image, MultiSelect, TextInput, Drawer, Accordion, Table, Badge, Alert |
4742
- | Project home | Tests/runs/artifacts/compare tabs, a two-run comparison with promise, outcome, recording and figure tabs per behavior, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
4770
+ | Project home | Tests/runs/artifacts/compare/catalog health tabs, a two-run comparison with promise, outcome, recording and figure tabs per behavior, a run's Artifacts (files of its layers, shared recordings, direct links), Watch playlist of a filtered set, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion, Modal, Grid, Switch |
4743
4771
  | Test detail | Current outcomes, statistics, duration graph, product latency with budgets and baselines, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
4744
4772
  | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
4745
4773
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
@@ -5829,9 +5857,9 @@ Diagnoses the report's metadata, so a domain filter or a coverage percentage can
5829
5857
 
5830
5858
  `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.
5831
5859
 
5832
- 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.
5860
+ 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.
5833
5861
 
5834
- - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
5862
+ - `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.
5835
5863
  - `format` (query): `markdown` for a Markdown report instead of JSON.
5836
5864
  - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
5837
5865
 
@@ -5845,12 +5873,12 @@ Diagnoses the report's metadata, so a domain filter or a coverage percentage can
5845
5873
 
5846
5874
  `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.
5847
5875
 
5848
- 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.
5876
+ 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.
5849
5877
 
5850
5878
  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.
5851
5879
 
5852
5880
  - `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.
5853
- - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
5881
+ - `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.
5854
5882
  - `format` (query): `markdown` for a Markdown report instead of JSON.
5855
5883
 
5856
5884
  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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Atlas: specs of any kind, reported as layered evidence. Publish checks from any runner (JUnit, CTRF, a script), combine them across nodes, branches, environments and backends, gate CI on the result, and run statecharts against the real app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -229,6 +229,18 @@
229
229
  "maximum": 9007199254740991,
230
230
  "description": "Checks whose id equals their title"
231
231
  },
232
+ "declaredBehaviorIds": {
233
+ "description": "Checks that declare a `behavior-id` label",
234
+ "type": "integer",
235
+ "minimum": 0,
236
+ "maximum": 9007199254740991
237
+ },
238
+ "declaredExampleIds": {
239
+ "description": "Checks that declare an `example-id` label",
240
+ "type": "integer",
241
+ "minimum": 0,
242
+ "maximum": 9007199254740991
243
+ },
232
244
  "basis": {
233
245
  "type": "string"
234
246
  }
@@ -278,6 +290,14 @@
278
290
  "minimum": 0,
279
291
  "maximum": 9007199254740991,
280
292
  "description": "Distinct paths the mapping changed"
293
+ },
294
+ "source": {
295
+ "description": "Where it was declared: the server's `catalog.roots` setting or this request's `root` parameter",
296
+ "type": "string",
297
+ "enum": [
298
+ "configuration",
299
+ "request"
300
+ ]
281
301
  }
282
302
  },
283
303
  "required": [
@@ -102,6 +102,23 @@
102
102
  },
103
103
  "additionalProperties": false
104
104
  },
105
+ "catalog": {
106
+ "default": {
107
+ "roots": []
108
+ },
109
+ "type": "object",
110
+ "properties": {
111
+ "roots": {
112
+ "default": [],
113
+ "description": "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",
114
+ "type": "array",
115
+ "items": {
116
+ "type": "string"
117
+ }
118
+ }
119
+ },
120
+ "additionalProperties": false
121
+ },
105
122
  "refreshSeconds": {
106
123
  "default": 10,
107
124
  "description": "How often the backends are looked at for layers written by something other than this server",
@@ -3699,7 +3699,7 @@
3699
3699
  "Report"
3700
3700
  ],
3701
3701
  "summary": "Why specs, domains and counts disagree",
3702
- "description": "Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.\n\n`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.\n\nTwo 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.\n\nRole needed: `read`.",
3702
+ "description": "Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.\n\n`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.\n\nTwo 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.\n\nRole needed: `read`.",
3703
3703
  "x-atlas-role": "read",
3704
3704
  "security": [
3705
3705
  {
@@ -3714,7 +3714,7 @@
3714
3714
  "required": false,
3715
3715
  "name": "root",
3716
3716
  "in": "query",
3717
- "description": "A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.",
3717
+ "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.",
3718
3718
  "example": ">specs/",
3719
3719
  "schema": {
3720
3720
  "type": "string"
@@ -3798,7 +3798,7 @@
3798
3798
  "Report"
3799
3799
  ],
3800
3800
  "summary": "Why specs, domains and counts disagree",
3801
- "description": "Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.\n\n`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.\n\nTwo 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.\n\nScoped 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.\n\nRole needed: `read`.",
3801
+ "description": "Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.\n\n`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.\n\nTwo 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.\n\nScoped 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.\n\nRole needed: `read`.",
3802
3802
  "x-atlas-role": "read",
3803
3803
  "security": [
3804
3804
  {
@@ -3825,7 +3825,7 @@
3825
3825
  "required": false,
3826
3826
  "name": "root",
3827
3827
  "in": "query",
3828
- "description": "A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.",
3828
+ "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.",
3829
3829
  "example": ">specs/",
3830
3830
  "schema": {
3831
3831
  "type": "string"
@@ -21772,6 +21772,18 @@
21772
21772
  "maximum": 9007199254740991,
21773
21773
  "description": "Checks whose id equals their title"
21774
21774
  },
21775
+ "declaredBehaviorIds": {
21776
+ "description": "Checks that declare a `behavior-id` label",
21777
+ "type": "integer",
21778
+ "minimum": 0,
21779
+ "maximum": 9007199254740991
21780
+ },
21781
+ "declaredExampleIds": {
21782
+ "description": "Checks that declare an `example-id` label",
21783
+ "type": "integer",
21784
+ "minimum": 0,
21785
+ "maximum": 9007199254740991
21786
+ },
21775
21787
  "basis": {
21776
21788
  "type": "string"
21777
21789
  }
@@ -21821,6 +21833,14 @@
21821
21833
  "minimum": 0,
21822
21834
  "maximum": 9007199254740991,
21823
21835
  "description": "Distinct paths the mapping changed"
21836
+ },
21837
+ "source": {
21838
+ "description": "Where it was declared: the server's `catalog.roots` setting or this request's `root` parameter",
21839
+ "type": "string",
21840
+ "enum": [
21841
+ "configuration",
21842
+ "request"
21843
+ ]
21824
21844
  }
21825
21845
  },
21826
21846
  "required": [
@@ -59,6 +59,7 @@ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
59
59
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
60
60
  import { ArtifactsView } from './ArtifactsView';
61
61
  import { CompareView } from './CompareView';
62
+ import { HealthView } from './HealthView';
62
63
  import { CollectionsView } from './collections';
63
64
  import { RunsView } from './RunsView';
64
65
  import { SearchNotes, TypedFilters } from './search-notes';
@@ -170,7 +171,7 @@ export function Explorer() {
170
171
  const model = catalog.model;
171
172
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
172
173
  const empty = model !== null && model.tests.length === 0;
173
- const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare';
174
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare' && search.tab !== 'health';
174
175
  return (
175
176
  <Container size="xl" py="xl">
176
177
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -181,7 +182,7 @@ export function Explorer() {
181
182
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
182
183
  <Tabs
183
184
  value={search.tab ?? 'tests'}
184
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
185
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
185
186
  >
186
187
  <Tabs.List aria-label="Explorer view">
187
188
  <Tabs.Tab
@@ -206,6 +207,7 @@ export function Explorer() {
206
207
  </Tabs.Tab>
207
208
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
208
209
  <Tabs.Tab value="compare">Compare</Tabs.Tab>
210
+ <Tabs.Tab value="health">Catalog health</Tabs.Tab>
209
211
  {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
210
212
  </Tabs.List>
211
213
  </Tabs>
@@ -243,6 +245,9 @@ export function Explorer() {
243
245
  </List.Item>
244
246
  ))}
245
247
  </List>
248
+ <Anchor mt="xs" display="inline-block" size="sm" renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'health', test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
249
+ Why do these counts differ from the Tests?
250
+ </Anchor>
246
251
  </Box>
247
252
  )}
248
253
  {!model && !evidence.unavailable && (
@@ -273,6 +278,8 @@ export function Explorer() {
273
278
  <ArtifactsView />
274
279
  ) : search.tab === 'compare' ? (
275
280
  <CompareView />
281
+ ) : search.tab === 'health' ? (
282
+ <HealthView />
276
283
  ) : search.tab === 'collections' ? (
277
284
  <CollectionsView />
278
285
  ) : empty ? null : (
@@ -0,0 +1,402 @@
1
+ /**
2
+ * @module Catalog health view
3
+ *
4
+ * The Catalog health tab: why the Specs and the Tests of a report disagree, and what a producer or an operator can change.
5
+ * It shows the denominators apart (spec files, defined specs, specs with an executed test, tests, outlines, examples and
6
+ * per-environment observations), the steps from the Specs count to the Tests count, the domains in use, specs seen under
7
+ * more than one path, and every finding with the paths it affects. Two paths are one logical spec only when a root mapping
8
+ * relates them: the server's configured mappings are shown as such, and further `from>to` mappings can be tried here
9
+ * (they are the address's `root`, so a link reproduces the figures). Ambiguous paths are never merged by similar title.
10
+ * The tab only reads: it never changes a status, a count of passed checks or a domain.
11
+ */
12
+ import { parseRootMappings, type CatalogHealth, type HealthFinding, type RootMapping } from '@crvouga/atlas/reports/catalog-health';
13
+ import { Accordion, ActionIcon, Alert, Badge, Box, Button, Code, Group, List, Paper, SimpleGrid, Stack, Table, Text, TextInput, Title, Tooltip } from '@mantine/core';
14
+ import { IconTrash } from '@tabler/icons-react';
15
+ import { useMemo, useState } from 'react';
16
+
17
+ import { plural } from '../evidence/format';
18
+ import { useCatalogHealth } from './catalog';
19
+ import { useExplorerNavigate, useExplorerSearch } from './navigate';
20
+
21
+ const label = (root: { readonly from: string; readonly to: string }) => `${root.from}>${root.to}`;
22
+
23
+ function Figure({ name, value, hint }: { name: string; value: number; hint: string }) {
24
+ return (
25
+ <Paper withBorder p="sm" title={hint}>
26
+ <Text size="xs" c="dimmed" component="div">
27
+ {name}
28
+ </Text>
29
+ <Text fw={700} size="xl" component="div" data-health-figure={name}>
30
+ {value}
31
+ </Text>
32
+ </Paper>
33
+ );
34
+ }
35
+
36
+ function Counts({ health }: { health: CatalogHealth }) {
37
+ const { specs, tests, scenarios } = health.counts;
38
+ return (
39
+ <Stack gap="md">
40
+ <Box component="section" aria-label="Spec counts">
41
+ <Title order={4} mb="xs">
42
+ Specs
43
+ </Title>
44
+ <SimpleGrid cols={{ base: 2, sm: 3, md: 5 }}>
45
+ <Figure name="Spec files" value={specs.files} hint="Files the report is laid over, before any root mapping. This is the number the Specs view shows." />
46
+ <Figure name="Defined specs" value={specs.defined} hint="Spec files, with those joined by a declared root mapping counted once." />
47
+ <Figure name="Specs with tests" value={specs.withTests} hint="Specs, defined or not, with at least one test. This is what the Tests view groups by." />
48
+ <Figure name="Specs executed" value={specs.executed} hint="Defined specs with a test that passed or failed: the denominator of execution coverage." />
49
+ <Figure name="Defined, not executed" value={specs.withoutExecution} hint="Defined specs with no passed or failed test: the coverage gaps. They add to no passed count." />
50
+ </SimpleGrid>
51
+ </Box>
52
+ <Box component="section" aria-label="Test counts">
53
+ <Title order={4} mb="xs">
54
+ Tests
55
+ </Title>
56
+ <SimpleGrid cols={{ base: 2, sm: 3, md: 5 }}>
57
+ <Figure name="Tests" value={tests.total} hint="Check identities: a spec path and an id, however many environments ran them." />
58
+ <Figure name="Tests passed" value={tests.passed} hint="Tests whose current status is passed." />
59
+ <Figure name="Tests with a spec" value={tests.withSpec} hint="Tests that name the spec they belong to." />
60
+ <Figure name="Tests without a spec" value={tests.withoutSpec} hint="Tests that name no spec; they are in no spec or domain group." />
61
+ <Figure name="Only in history" value={tests.historicalOnly} hint="Tests the history holds but the report has no current result for (removed, renamed or not re-run)." />
62
+ </SimpleGrid>
63
+ </Box>
64
+ <Box component="section" aria-label="Scenario counts">
65
+ <Title order={4} mb="xs">
66
+ Outlines, examples and observations
67
+ </Title>
68
+ <SimpleGrid cols={{ base: 2, sm: 3, md: 5 }}>
69
+ <Figure name="Outlines" value={scenarios.outlines} hint="Tests sharing the label outline count once; a test without it is its own outline." />
70
+ <Figure name="Examples" value={scenarios.examples} hint="Concrete examples: tests, however many environments ran them." />
71
+ <Figure name="Observations" value={scenarios.observations} hint="Current results per producer, environment and parameter set." />
72
+ <Figure name="Observations executed" value={scenarios.executedObservations} hint="Observations that passed or failed." />
73
+ <Figure name="Observations passed" value={scenarios.passedObservations} hint="Observations that passed." />
74
+ </SimpleGrid>
75
+ </Box>
76
+ </Stack>
77
+ );
78
+ }
79
+
80
+ function Reconciliation({ health }: { health: CatalogHealth }) {
81
+ const { reconciliation } = health;
82
+ let total = reconciliation.specs;
83
+ const rows = reconciliation.steps.map((step) => {
84
+ total += step.delta;
85
+ return (
86
+ <Table.Tr key={step.code}>
87
+ <Table.Td>{step.label}</Table.Td>
88
+ <Table.Td ta="right">{step.delta > 0 ? `+${step.delta}` : step.delta}</Table.Td>
89
+ <Table.Td ta="right">{total}</Table.Td>
90
+ </Table.Tr>
91
+ );
92
+ });
93
+ return (
94
+ <Box component="section" aria-label="Why Specs and Tests differ">
95
+ <Title order={3} mb="xs">
96
+ Why Specs and Tests differ
97
+ </Title>
98
+ <Table.ScrollContainer minWidth={420}>
99
+ <Table data-health-reconciliation>
100
+ <Table.Thead>
101
+ <Table.Tr>
102
+ <Table.Th scope="col">Step</Table.Th>
103
+ <Table.Th scope="col" ta="right">
104
+ Change
105
+ </Table.Th>
106
+ <Table.Th scope="col" ta="right">
107
+ Total
108
+ </Table.Th>
109
+ </Table.Tr>
110
+ </Table.Thead>
111
+ <Table.Tbody>
112
+ <Table.Tr>
113
+ <Table.Td>Spec files the Specs view lists</Table.Td>
114
+ <Table.Td />
115
+ <Table.Td ta="right">{reconciliation.specs}</Table.Td>
116
+ </Table.Tr>
117
+ {rows}
118
+ <Table.Tr>
119
+ <Table.Td fw={600}>Specs the Tests view groups its tests into</Table.Td>
120
+ <Table.Td />
121
+ <Table.Td ta="right" fw={600}>
122
+ {reconciliation.testSpecs}
123
+ </Table.Td>
124
+ </Table.Tr>
125
+ </Table.Tbody>
126
+ </Table>
127
+ </Table.ScrollContainer>
128
+ <List size="sm" mt="sm" data-health-explanation>
129
+ {reconciliation.explanation.map((line) => (
130
+ <List.Item key={line}>{line}</List.Item>
131
+ ))}
132
+ </List>
133
+ </Box>
134
+ );
135
+ }
136
+
137
+ function Finding({ item }: { item: HealthFinding }) {
138
+ return (
139
+ <Accordion.Item value={item.code} data-health-finding={item.code}>
140
+ <Accordion.Control>
141
+ <Group gap="xs" wrap="wrap">
142
+ <Badge variant="light" color={item.severity === 'warning' ? 'orange' : 'blue'} tt="none">
143
+ {item.severity === 'warning' ? 'Warning' : 'Note'}
144
+ </Badge>
145
+ <Code>{item.code}</Code>
146
+ <Text span size="sm" c="dimmed">
147
+ {item.count} affected
148
+ </Text>
149
+ </Group>
150
+ </Accordion.Control>
151
+ <Accordion.Panel>
152
+ <Stack gap="xs">
153
+ <Text component="div" size="sm">
154
+ {item.message}
155
+ </Text>
156
+ {item.items.length > 0 && (
157
+ <List size="sm" listStyleType="none">
158
+ {item.items.map((entry) => (
159
+ <List.Item key={entry.path}>
160
+ <Code>{entry.path}</Code>
161
+ {entry.candidates?.map((candidate) => (
162
+ <Text key={candidate.path} component="div" size="xs" c="dimmed" ml="md">
163
+ may be the same promise as <Code>{candidate.path}</Code> ({candidate.reason}); not merged
164
+ </Text>
165
+ ))}
166
+ </List.Item>
167
+ ))}
168
+ </List>
169
+ )}
170
+ {item.count > item.items.length && (
171
+ <Text size="xs" c="dimmed" component="div">
172
+ and {item.count - item.items.length} more
173
+ </Text>
174
+ )}
175
+ </Stack>
176
+ </Accordion.Panel>
177
+ </Accordion.Item>
178
+ );
179
+ }
180
+
181
+ function Mappings({ health, tried }: { health: CatalogHealth | undefined; tried: readonly RootMapping[] }) {
182
+ const go = useExplorerNavigate();
183
+ const [from, setFrom] = useState('');
184
+ const [to, setTo] = useState('');
185
+ const configured = (health?.roots ?? []).filter((root) => root.source === 'configuration');
186
+ const write = (list: readonly RootMapping[]) => go({ root: list.length ? list.map(label).join(',') : undefined }, { replace: true });
187
+ const known = [...configured, ...tried].some((root) => root.from === from.trim() && root.to === to.trim());
188
+ const matched = (root: RootMapping) => health?.roots.find((item) => item.from === root.from && item.to === root.to && item.source !== 'configuration')?.matched;
189
+ return (
190
+ <Box component="section" aria-label="Root mappings">
191
+ <Title order={3} mb="xs">
192
+ Root mappings
193
+ </Title>
194
+ <Text size="sm" c="dimmed" mb="sm">
195
+ Two spec paths are one spec only when a mapping says so. A mapping states that paths under one folder are the same specs as paths under another; Atlas never merges paths by a similar title. A mapping tried here is kept in the address and changes nothing on the server. To keep one, set <Code>catalog.roots</Code> or <Code>ATLAS_CATALOG_ROOTS</Code> on the server.
196
+ </Text>
197
+ {configured.length + tried.length > 0 && (
198
+ <List size="sm" listStyleType="none" mb="sm" aria-label="Declared root mappings">
199
+ {configured.map((root) => (
200
+ <List.Item key={`c${label(root)}`}>
201
+ <Group gap="xs" wrap="wrap">
202
+ <Code>{label(root)}</Code>
203
+ <Badge variant="light" tt="none">
204
+ Configured on the server
205
+ </Badge>
206
+ <Text span size="xs" c="dimmed">
207
+ changed {plural(root.matched, 'path', 'paths')}
208
+ </Text>
209
+ </Group>
210
+ </List.Item>
211
+ ))}
212
+ {tried.map((root) => (
213
+ <List.Item key={`t${label(root)}`}>
214
+ <Group gap="xs" wrap="wrap">
215
+ <Code>{label(root)}</Code>
216
+ <Badge variant="light" color="gray" tt="none">
217
+ Tried here
218
+ </Badge>
219
+ {matched(root) !== undefined && (
220
+ <Text span size="xs" c="dimmed">
221
+ changed {plural(matched(root)!, 'path', 'paths')}
222
+ </Text>
223
+ )}
224
+ <Tooltip label="Stop trying this mapping">
225
+ <ActionIcon variant="subtle" color="gray" aria-label={`Stop trying ${label(root)}`} onClick={() => write(tried.filter((item) => item !== root))}>
226
+ <IconTrash size={16} aria-hidden="true" />
227
+ </ActionIcon>
228
+ </Tooltip>
229
+ </Group>
230
+ </List.Item>
231
+ ))}
232
+ </List>
233
+ )}
234
+ <form
235
+ onSubmit={(event) => {
236
+ event.preventDefault();
237
+ if (!to.trim() || known) return;
238
+ write([...tried, { from: from.trim(), to: to.trim() }]);
239
+ setFrom('');
240
+ setTo('');
241
+ }}
242
+ >
243
+ <Group align="flex-end" gap="md" wrap="wrap">
244
+ <TextInput label="Paths under" description="Leave empty to match every path" placeholder="checkout/" value={from} onChange={(event) => setFrom(event.currentTarget.value)} />
245
+ <TextInput label="are the same specs as paths under" description="Where the specs are recorded" placeholder="specs/checkout/" value={to} onChange={(event) => setTo(event.currentTarget.value)} />
246
+ <Button type="submit" disabled={!to.trim() || known}>
247
+ Try this mapping
248
+ </Button>
249
+ </Group>
250
+ </form>
251
+ </Box>
252
+ );
253
+ }
254
+
255
+ /** The Catalog health tab. */
256
+ export function HealthView() {
257
+ const search = useExplorerSearch();
258
+ const tried = useMemo(() => (search.root ? parseRootMappings(new URLSearchParams([['root', search.root]])) : []), [search.root]);
259
+ const answer = useCatalogHealth(tried);
260
+ const health = answer.data && !answer.data.unsupported ? answer.data.health : undefined;
261
+ const warnings = health?.findings.filter((item) => item.severity === 'warning') ?? [];
262
+ return (
263
+ <Box aria-label="Catalog health" component="section" mb="lg">
264
+ <Title order={2} mb="sm">
265
+ Catalog health
266
+ </Title>
267
+ <Text component="div" c="dimmed" mb="md">
268
+ Specs, domains and coverage counts can disagree because of how specs are named and described, not because of a defect in a test. This explains each number, where the totals differ, and what to change at the source. It only reads: it changes no result.
269
+ </Text>
270
+ <Stack gap="lg">
271
+ <Mappings health={health} tried={tried} />
272
+ {answer.isPending && (
273
+ <Text role="status" component="div" c="dimmed">
274
+ Reading the catalog…
275
+ </Text>
276
+ )}
277
+ {answer.isError && (
278
+ <Alert role="alert" color="red">
279
+ The catalog health could not be read. {answer.error.message}
280
+ <Group mt="xs">
281
+ <Button type="button" variant="light" onClick={() => void answer.refetch()}>
282
+ Try again
283
+ </Button>
284
+ </Group>
285
+ </Alert>
286
+ )}
287
+ {answer.data?.unsupported && (
288
+ <Alert role="status" color="blue">
289
+ This server is older than the catalog health report, so it cannot explain these counts. Upgrade the server to read it.
290
+ </Alert>
291
+ )}
292
+ {health && (
293
+ <>
294
+ <Alert role="status" color={health.ok ? 'green' : 'orange'} title={health.ok ? 'No warnings' : plural(warnings.length, 'warning', 'warnings')} data-health-summary>
295
+ {health.ok ? 'Nothing found that makes a count or a domain filter misleading.' : 'These make a count or a domain filter misleading. Each says what to change at its source.'}
296
+ </Alert>
297
+ <Reconciliation health={health} />
298
+ <Counts health={health} />
299
+ {health.aliases.length > 0 && (
300
+ <Box component="section" aria-label="Specs under more than one path">
301
+ <Title order={3} mb="xs">
302
+ Specs under more than one path
303
+ </Title>
304
+ <Text size="sm" c="dimmed" mb="xs">
305
+ Joined because a root mapping relates them. Every original path stays inspectable.
306
+ </Text>
307
+ <Table.ScrollContainer minWidth={420}>
308
+ <Table data-health-aliases>
309
+ <Table.Thead>
310
+ <Table.Tr>
311
+ <Table.Th scope="col">Spec</Table.Th>
312
+ <Table.Th scope="col">Original paths</Table.Th>
313
+ </Table.Tr>
314
+ </Table.Thead>
315
+ <Table.Tbody>
316
+ {health.aliases.map((alias) => (
317
+ <Table.Tr key={alias.path}>
318
+ <Table.Td>
319
+ <Code>{alias.path}</Code>
320
+ </Table.Td>
321
+ <Table.Td>
322
+ <Stack gap={2}>
323
+ {alias.sources.map((source) => (
324
+ <Code key={source}>{source}</Code>
325
+ ))}
326
+ </Stack>
327
+ </Table.Td>
328
+ </Table.Tr>
329
+ ))}
330
+ </Table.Tbody>
331
+ </Table>
332
+ </Table.ScrollContainer>
333
+ </Box>
334
+ )}
335
+ <Box component="section" aria-label="Domains">
336
+ <Title order={3} mb="xs">
337
+ Domains
338
+ </Title>
339
+ <Text size="sm" c="dimmed" mb="xs">
340
+ A domain is what a spec&apos;s own metadata says. Explicit metadata is always authoritative; a domain shared by nearly every spec is reported as a finding below, not changed.
341
+ </Text>
342
+ {health.domains.length === 0 ? (
343
+ <Text component="div" c="dimmed">
344
+ No spec declares a domain.
345
+ </Text>
346
+ ) : (
347
+ <Table.ScrollContainer minWidth={320}>
348
+ <Table data-health-domains>
349
+ <Table.Thead>
350
+ <Table.Tr>
351
+ <Table.Th scope="col">Domain</Table.Th>
352
+ <Table.Th scope="col" ta="right">
353
+ Specs
354
+ </Table.Th>
355
+ </Table.Tr>
356
+ </Table.Thead>
357
+ <Table.Tbody>
358
+ {health.domains.map((domain) => (
359
+ <Table.Tr key={domain.name}>
360
+ <Table.Td>{domain.name}</Table.Td>
361
+ <Table.Td ta="right">{domain.specs}</Table.Td>
362
+ </Table.Tr>
363
+ ))}
364
+ </Table.Tbody>
365
+ </Table>
366
+ </Table.ScrollContainer>
367
+ )}
368
+ </Box>
369
+ <Box component="section" aria-label="Findings">
370
+ <Title order={3} mb="xs">
371
+ Findings
372
+ </Title>
373
+ {health.findings.length === 0 ? (
374
+ <Text component="div" c="dimmed">
375
+ Nothing to report.
376
+ </Text>
377
+ ) : (
378
+ <Accordion multiple defaultValue={warnings.map((item) => item.code)}>
379
+ {health.findings.map((item) => (
380
+ <Finding key={item.code} item={item} />
381
+ ))}
382
+ </Accordion>
383
+ )}
384
+ </Box>
385
+ <Box component="section" aria-label="Identity">
386
+ <Title order={3} mb="xs">
387
+ Identity
388
+ </Title>
389
+ <Text size="sm" component="div" data-health-identity>
390
+ Fallback identity (known by title only): {health.identity.fallbackIdentity} of {health.identity.checks} checks.
391
+ {health.identity.declaredBehaviorIds !== undefined && ` Declared behavior ids: ${health.identity.declaredBehaviorIds}. Declared example ids: ${health.identity.declaredExampleIds ?? 0}.`}
392
+ </Text>
393
+ <Text size="sm" c="dimmed" component="div" mt="xs">
394
+ {health.identity.basis}
395
+ </Text>
396
+ </Box>
397
+ </>
398
+ )}
399
+ </Stack>
400
+ </Box>
401
+ );
402
+ }
@@ -26,6 +26,7 @@ import {
26
26
  type FilterName,
27
27
  type QueryFacets
28
28
  } from '@crvouga/atlas/reports/artifact-query';
29
+ import { catalogHealth, type CatalogHealth, type RootMapping } from '@crvouga/atlas/reports/catalog-health';
29
30
  import {
30
31
  artifactList,
31
32
  catalogModel,
@@ -371,3 +372,35 @@ export function useRunDetail(key: string | undefined) {
371
372
  retry: false
372
373
  });
373
374
  }
375
+
376
+ /** What the Catalog health tab reads: the diagnostics, or that this server is too old to give them. */
377
+ export type HealthAnswer = { readonly unsupported: false; readonly health: CatalogHealth } | { readonly unsupported: true };
378
+
379
+ /**
380
+ * Why the Specs, domains and counts of the report disagree. From the server when it has one (it also applies the root
381
+ * mappings it is configured with, and says so in `roots[].source`); otherwise worked out here from the specs and
382
+ * results this page holds. A server without the route answers `unsupported`, and the page says so.
383
+ */
384
+ export function useCatalogHealth(roots: readonly RootMapping[]) {
385
+ const catalog = useTestCatalog();
386
+ const asked = roots.map((root) => `${root.from}>${root.to}`);
387
+ return useQuery({
388
+ queryKey: ['catalog-health', catalog.version, asked.join('\u0001')],
389
+ queryFn: async ({ signal }): Promise<HealthAnswer> => {
390
+ if (catalog.source === 'server') {
391
+ const params = new URLSearchParams(asked.map((root) => ['root', root]));
392
+ try {
393
+ return { unsupported: false, health: await reportJson<CatalogHealth>(`/tests/health${params.size ? `?${params}` : ''}`, signal) };
394
+ } catch (error) {
395
+ if (notFound(error)) return { unsupported: true };
396
+ throw error;
397
+ }
398
+ }
399
+ return { unsupported: false, health: catalogHealth(catalog.model!, roots) };
400
+ },
401
+ enabled: catalog.model !== null,
402
+ placeholderData: keepPreviousData,
403
+ staleTime: Number.POSITIVE_INFINITY,
404
+ retry: false
405
+ });
406
+ }
@@ -2,7 +2,7 @@
2
2
  * @module Explorer address
3
3
  *
4
4
  * Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
5
- * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
5
+ * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
6
6
  * take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
7
7
  * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
8
  *
@@ -63,7 +63,9 @@ export const ExplorerSearchFields = {
63
63
  inRun: text,
64
64
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
65
65
  sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
66
- tab: z.enum(['runs', 'artifacts', 'collections', 'compare']).optional().catch(undefined),
66
+ tab: z.enum(['runs', 'artifacts', 'collections', 'compare', 'health']).optional().catch(undefined),
67
+ /** The root mappings (`from>to`, comma-separated) the Catalog health tab tries, beside those the server is configured with. */
68
+ root: text,
67
69
  /** The review collection opened in the Collections tab (its stable id), and the revision of it (default: the newest). */
68
70
  collection: text,
69
71
  rev: z.coerce.number().int().positive().optional().catch(undefined),
@@ -127,7 +129,8 @@ export type ExplorerSearch = {
127
129
  readonly inRun?: string;
128
130
  readonly group?: TestGroup;
129
131
  readonly sort?: ExplorerSort;
130
- readonly tab?: 'runs' | 'artifacts' | 'collections' | 'compare';
132
+ readonly tab?: 'runs' | 'artifacts' | 'collections' | 'compare' | 'health';
133
+ readonly root?: string;
131
134
  readonly collection?: string;
132
135
  readonly rev?: number;
133
136
  readonly base?: string;