@crvouga/atlas 0.11.0 → 0.12.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,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.12.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - diagnose spec identity, domain and coverage count inconsistencies (#44) ([4a66a62](https://github.com/crvouga/atlas/commit/4a66a627fc62a3f38ad7ce9af927427a7b522f9a))
8
+
3
9
  ## 0.11.0 (2026-10-25)
4
10
 
5
11
  ### Features
@@ -0,0 +1,142 @@
1
+ import type { CatalogModel } from './test-catalog.js';
2
+ /** The version of the `CatalogHealth` document. Only optional fields are added within it. */
3
+ export declare const CATALOG_HEALTH_VERSION = 1;
4
+ /** The most paths listed in one finding; its `count` is always the whole. */
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. */
7
+ export type RootMapping = {
8
+ readonly from: string;
9
+ readonly to: string;
10
+ };
11
+ /** One thing worth a look. */
12
+ export type HealthItem = {
13
+ readonly path: string;
14
+ /** Other paths it could be the same promise as, when any; Atlas has not merged them. */
15
+ readonly candidates?: readonly {
16
+ readonly path: string;
17
+ readonly reason: string;
18
+ }[];
19
+ };
20
+ /** 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';
22
+ /** A finding: what is inconsistent, how many it affects, and what to change. */
23
+ export type HealthFinding = {
24
+ readonly code: HealthCode;
25
+ /** `warning` is something that makes a count or a filter misleading; `info` explains a number. */
26
+ readonly severity: 'warning' | 'info';
27
+ readonly message: string;
28
+ /** How many paths, specs or tests it affects. */
29
+ readonly count: number;
30
+ /** Up to `HEALTH_ITEM_LIMIT` of them. */
31
+ readonly items: readonly HealthItem[];
32
+ };
33
+ /** A spec that reached Atlas under more than one path and was joined by a declared root mapping. */
34
+ export type SpecAlias = {
35
+ /** The logical spec's path after mapping. */
36
+ readonly path: string;
37
+ /** Every original path it was seen under, sorted. */
38
+ readonly sources: readonly string[];
39
+ };
40
+ /** One step from the Specs count to the Tests count. */
41
+ export type ReconciliationStep = {
42
+ readonly code: 'merged-by-mapping' | 'defined-without-results' | 'results-without-definition';
43
+ readonly label: string;
44
+ /** The change to the running total: negative removes specs, positive adds them. */
45
+ readonly delta: number;
46
+ };
47
+ /** The `GET /api/reports/tests/health` document. */
48
+ export type CatalogHealth = {
49
+ readonly schemaVersion: typeof CATALOG_HEALTH_VERSION;
50
+ readonly version: string;
51
+ readonly generatedAtMs: number;
52
+ /** True when there is no warning. Informational findings do not count. */
53
+ readonly ok: boolean;
54
+ readonly counts: {
55
+ readonly specs: {
56
+ /** Spec files the report is laid over (before mapping). */
57
+ readonly files: number;
58
+ /** Logical specs among them: files joined by a root mapping count once. */
59
+ readonly defined: number;
60
+ /** Logical specs, defined or not, with at least one test. */
61
+ readonly withTests: number;
62
+ /** Defined logical specs with a test that has a passed or failed result. They are the denominator of execution coverage. */
63
+ readonly executed: number;
64
+ /** Defined logical specs with no test that has a passed or failed result: the defined coverage gaps. */
65
+ readonly withoutExecution: number;
66
+ };
67
+ readonly tests: {
68
+ readonly total: number;
69
+ readonly withSpec: number;
70
+ readonly withoutSpec: number;
71
+ /** Tests whose current status is `passed`. A definition with no test never adds to it. */
72
+ readonly passed: number;
73
+ /** Tests the history holds but the report has no current result for. */
74
+ readonly historicalOnly: number;
75
+ };
76
+ readonly scenarios: {
77
+ /** Outlines: tests sharing the label `outline` count once; a test without it is its own outline. */
78
+ readonly outlines: number;
79
+ /** Concrete examples: tests (a check identity), however many environments ran them. */
80
+ readonly examples: number;
81
+ /** Current results per producer, environment and parameter set. */
82
+ readonly observations: number;
83
+ /** Observations that passed or failed. */
84
+ readonly executedObservations: number;
85
+ readonly passedObservations: number;
86
+ };
87
+ };
88
+ readonly reconciliation: {
89
+ /** What the Specs view counts: spec files. */
90
+ readonly specs: number;
91
+ /** What the Tests view groups by: logical specs with at least one test. */
92
+ readonly testSpecs: number;
93
+ readonly steps: readonly ReconciliationStep[];
94
+ /** Tests that name no spec; they are in no group above. */
95
+ readonly testsWithoutSpec: number;
96
+ /** In words, for a person or an agent. */
97
+ readonly explanation: readonly string[];
98
+ };
99
+ readonly identity: {
100
+ readonly checks: number;
101
+ /** Checks whose id equals their title: no stable id was given. */
102
+ readonly fallbackIdentity: number;
103
+ readonly basis: string;
104
+ };
105
+ readonly domains: readonly {
106
+ readonly name: string;
107
+ readonly specs: number;
108
+ }[];
109
+ readonly roots: readonly {
110
+ readonly from: string;
111
+ readonly to: string;
112
+ readonly matched: number;
113
+ }[];
114
+ readonly aliases: readonly SpecAlias[];
115
+ readonly findings: readonly HealthFinding[];
116
+ };
117
+ /** Parses `from>to` (as `?root=` gives it) into a mapping. Returns null when there is no `>`. Whitespace around each side is trimmed. */
118
+ export declare function parseRootMapping(text: string): RootMapping | null;
119
+ /** Reads the repeatable `root` search parameter: each value `from>to`, several may be comma-separated. Values without `>` are ignored. */
120
+ export declare function parseRootMappings(params: URLSearchParams): RootMapping[];
121
+ /**
122
+ * The logical path of a spec path: the path Atlas already normalises (`specPath`), after the first mapping whose `from`
123
+ * is a prefix of it (the longest `from` wins). A path already under `to` is left alone. Returns the path and the mapping
124
+ * that changed it, if any.
125
+ */
126
+ export declare function canonicalSpecPath(path: string, roots: readonly RootMapping[]): {
127
+ readonly path: string;
128
+ readonly mapping: RootMapping | null;
129
+ };
130
+ /**
131
+ * Diagnoses a report's spec identity, domains and counts. `roots` are the declared path-root mappings. Pure: the same
132
+ * model and mappings give the same document, and nothing in it changes what the report says passed.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * const health = catalogHealth(model, [{ from: '', to: 'specs/' }]);
137
+ * if (!health.ok) console.log(healthMarkdown(health));
138
+ * ```
139
+ */
140
+ export declare function catalogHealth(model: CatalogModel, roots?: readonly RootMapping[]): CatalogHealth;
141
+ /** The health document as Markdown, for an agent or a terminal. */
142
+ export declare function healthMarkdown(health: CatalogHealth): string;
@@ -0,0 +1,257 @@
1
+ import { specPath } from './layer-merge.js';
2
+ /** The version of the `CatalogHealth` document. Only optional fields are added within it. */
3
+ export const CATALOG_HEALTH_VERSION = 1;
4
+ /** The most paths listed in one finding; its `count` is always the whole. */
5
+ export const HEALTH_ITEM_LIMIT = 25;
6
+ /** The most candidates named for one ambiguous path. */
7
+ const CANDIDATE_LIMIT = 5;
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
+ const GENERIC_DOMAINS = new Set(['specs', 'spec', 'product', 'features', 'feature', 'tests', 'test', 'e2e', 'src', 'app', 'docs', 'overview', 'root', 'default']);
10
+ /** Parses `from>to` (as `?root=` gives it) into a mapping. Returns null when there is no `>`. Whitespace around each side is trimmed. */
11
+ export function parseRootMapping(text) {
12
+ const at = text.indexOf('>');
13
+ if (at < 0)
14
+ return null;
15
+ return { from: text.slice(0, at).trim(), to: text.slice(at + 1).trim() };
16
+ }
17
+ /** Reads the repeatable `root` search parameter: each value `from>to`, several may be comma-separated. Values without `>` are ignored. */
18
+ export function parseRootMappings(params) {
19
+ return params.getAll('root').flatMap(value => value.split(',')).map(parseRootMapping).filter((mapping) => mapping !== null);
20
+ }
21
+ const slashes = (path) => path.replaceAll('\\', '/').replace(/^\.\//, '');
22
+ const folder = (prefix) => (prefix === '' || prefix.endsWith('/') ? prefix : `${prefix}/`);
23
+ /**
24
+ * The logical path of a spec path: the path Atlas already normalises (`specPath`), after the first mapping whose `from`
25
+ * is a prefix of it (the longest `from` wins). A path already under `to` is left alone. Returns the path and the mapping
26
+ * that changed it, if any.
27
+ */
28
+ export function canonicalSpecPath(path, roots) {
29
+ const raw = slashes(path);
30
+ for (const mapping of roots) {
31
+ const from = folder(mapping.from);
32
+ const to = folder(mapping.to);
33
+ for (const candidate of [raw, specPath(raw)]) {
34
+ if (!candidate.startsWith(from) || (to !== '' && candidate.startsWith(to)))
35
+ continue;
36
+ const mapped = specPath(to + candidate.slice(from.length));
37
+ if (mapped !== specPath(raw))
38
+ return { path: mapped, mapping };
39
+ }
40
+ }
41
+ return { path: specPath(raw), mapping: null };
42
+ }
43
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
44
+ const executed = (status) => status === 'passed' || status === 'failed';
45
+ const tail = (path) => path.split('/').at(-1).toLowerCase();
46
+ /** Why two paths with the same file name might be one promise: one ends with the other, or only the name is shared. */
47
+ function relation(a, b) {
48
+ return a.endsWith(`/${b}`) || b.endsWith(`/${a}`) ? 'one path ends with the other' : 'same file name';
49
+ }
50
+ function finding(code, severity, message, items, count = items.length) {
51
+ return { code, severity, message, count, items: items.slice(0, HEALTH_ITEM_LIMIT) };
52
+ }
53
+ /**
54
+ * Diagnoses a report's spec identity, domains and counts. `roots` are the declared path-root mappings. Pure: the same
55
+ * model and mappings give the same document, and nothing in it changes what the report says passed.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const health = catalogHealth(model, [{ from: '', to: 'specs/' }]);
60
+ * if (!health.ok) console.log(healthMarkdown(health));
61
+ * ```
62
+ */
63
+ export function catalogHealth(model, roots = []) {
64
+ const ordered = [...roots].sort((a, b) => b.from.length - a.from.length);
65
+ const matched = new Map(ordered.map(mapping => [mapping, new Set()]));
66
+ const groups = new Map();
67
+ const group = (path) => {
68
+ const { path: key, mapping } = canonicalSpecPath(path, ordered);
69
+ if (mapping)
70
+ matched.get(mapping)?.add(path);
71
+ let found = groups.get(key);
72
+ if (!found)
73
+ groups.set(key, found = { key, defined: new Map(), results: new Set(), tests: [] });
74
+ return found;
75
+ };
76
+ const specs = model.specs ?? [];
77
+ for (const spec of specs)
78
+ group(spec.path).defined.set(spec.path, spec);
79
+ for (const test of model.tests) {
80
+ if (test.specPath === null)
81
+ continue;
82
+ const found = group(test.specPath);
83
+ found.results.add(test.specPath);
84
+ found.tests.push(test);
85
+ }
86
+ const all = [...groups.values()].sort((a, b) => byText(a.key, b.key));
87
+ const definedGroups = all.filter(item => item.defined.size > 0);
88
+ const withTests = all.filter(item => item.tests.length > 0);
89
+ const resultsOnly = all.filter(item => item.defined.size === 0);
90
+ const definedOnly = definedGroups.filter(item => item.tests.length === 0);
91
+ const withoutExecution = definedGroups.filter(item => !item.tests.some(test => test.outcomes.some(outcome => executed(outcome.status))));
92
+ const files = specs.length;
93
+ const hasOutline = files > 0;
94
+ const findings = [];
95
+ // Aliases: one logical spec under several original paths. Joined only because a mapping said so.
96
+ const aliases = all
97
+ .map(item => ({ path: item.key, sources: [...new Set([...item.defined.keys(), ...item.results])].sort(byText) }))
98
+ .filter(item => item.sources.length > 1);
99
+ // Ambiguity: a path with results and no definition, beside a definition with no results, that might be the same promise.
100
+ if (hasOutline) {
101
+ const byName = new Map();
102
+ for (const item of definedOnly)
103
+ for (const path of item.defined.keys()) {
104
+ const name = tail(path);
105
+ byName.set(name, [...(byName.get(name) ?? []), item]);
106
+ }
107
+ const ambiguous = [];
108
+ const unmatched = [];
109
+ for (const item of resultsOnly) {
110
+ for (const path of [...item.results].sort(byText)) {
111
+ const candidates = new Map();
112
+ for (const near of byName.get(tail(path)) ?? [])
113
+ for (const other of near.defined.keys())
114
+ if (tail(other) === tail(path))
115
+ candidates.set(other, relation(path, other));
116
+ if (candidates.size)
117
+ ambiguous.push({ path, candidates: [...candidates].sort(([a], [b]) => byText(a, b)).slice(0, CANDIDATE_LIMIT).map(([candidate, reason]) => ({ path: candidate, reason })) });
118
+ else
119
+ unmatched.push({ path });
120
+ }
121
+ }
122
+ if (ambiguous.length)
123
+ findings.push(finding('ambiguous-spec-path', 'warning', 'These paths have results but no definition, and a defined spec with no results looks like the same promise. Atlas has not merged them. If they are one, declare a root mapping (`?root=<from>><to>`); if not, nothing to do.', ambiguous));
124
+ if (unmatched.length)
125
+ findings.push(finding('unmatched-spec-path', 'warning', 'These paths have results but no definition in the specs the report is laid over, and nothing looks like them. Publish the spec set that defines them, or correct the path the producer records.', unmatched));
126
+ }
127
+ else if (withTests.length) {
128
+ findings.push(finding('no-spec-definitions', 'info', 'No specs are defined for this report, so each test\'s spec and domain come from the path of its results alone. Publish the specs with the results (`--specs <dir>`) to define them.', [], withTests.length));
129
+ }
130
+ // Duplicates among definitions: the same content, or the same title, under different logical paths.
131
+ const byFingerprint = new Map();
132
+ const byTitle = new Map();
133
+ for (const item of definedGroups) {
134
+ const spec = [...item.defined.values()].sort((a, b) => byText(a.path, b.path))[0];
135
+ if (spec.fingerprint)
136
+ byFingerprint.set(spec.fingerprint, [...(byFingerprint.get(spec.fingerprint) ?? []), item.key]);
137
+ const title = spec.title.trim().toLowerCase();
138
+ if (title && title !== tail(item.key))
139
+ byTitle.set(title, [...(byTitle.get(title) ?? []), { path: item.key, fingerprint: spec.fingerprint ?? '' }]);
140
+ }
141
+ const sameContent = [...byFingerprint.values()].filter(paths => paths.length > 1);
142
+ if (sameContent.length)
143
+ findings.push(finding('duplicate-spec-content', 'warning', 'These specs have identical content under different paths. They are counted separately; if they are one promise, declare a root mapping.', sameContent.flatMap(paths => paths.map(path => ({ path, candidates: paths.filter(other => other !== path).slice(0, CANDIDATE_LIMIT).map(other => ({ path: other, reason: 'identical content' })) })))));
144
+ const sameTitle = [...byTitle.values()].filter(list => list.length > 1 && new Set(list.map(item => item.fingerprint)).size > 1);
145
+ if (sameTitle.length)
146
+ findings.push(finding('duplicate-spec-title', 'info', 'These specs share a title but differ in content or path. Atlas keeps them apart; the title alone is never an identity.', sameTitle.flatMap(list => list.map(item => ({ path: item.path, candidates: list.filter(other => other.path !== item.path).slice(0, CANDIDATE_LIMIT).map(other => ({ path: other.path, reason: 'same title' })) })))));
147
+ // Domains over logical specs.
148
+ const domainCounts = new Map();
149
+ for (const item of all) {
150
+ const domain = [...item.defined.values()].sort((a, b) => byText(a.path, b.path))[0]?.domain || item.tests[0]?.spec?.domain || '';
151
+ if (domain)
152
+ domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
153
+ }
154
+ const domains = [...domainCounts].map(([name, specs]) => ({ name, specs })).sort((a, b) => b.specs - a.specs || byText(a.name, b.name));
155
+ const withDomain = domains.reduce((sum, domain) => sum + domain.specs, 0);
156
+ const top = domains[0];
157
+ if (top && withDomain >= 3 && top.specs / withDomain >= 0.8) {
158
+ const generic = GENERIC_DOMAINS.has(top.name.toLowerCase());
159
+ findings.push(finding('generic-domain', 'warning', `${top.specs} of ${withDomain} specs are in the domain "${top.name}"${generic ? ', which looks like a root folder rather than an area of the product' : ''}, so filtering by domain will not separate them. Give specs an explicit domain (the spec set's \`domain\`, or each spec entry's); explicit metadata always wins over the folder.`, [], top.specs));
160
+ }
161
+ const testsWithoutSpec = model.tests.filter(test => test.specPath === null);
162
+ if (testsWithoutSpec.length)
163
+ findings.push(finding('tests-without-spec', 'info', 'These tests name no spec, so they are in no spec or domain group (the Tests view shows them under "(no spec)").', testsWithoutSpec.map(test => ({ path: test.id })), testsWithoutSpec.length));
164
+ const fallback = model.tests.filter(test => test.id === test.title);
165
+ if (fallback.length)
166
+ 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));
167
+ const unused = ordered.filter(mapping => !matched.get(mapping)?.size);
168
+ if (unused.length)
169
+ 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}` }))));
170
+ if (withoutExecution.length)
171
+ findings.push(finding('spec-without-execution', 'info', 'These defined specs have no test with a passed or failed result. They are coverage gaps; they add nothing to any passed count.', withoutExecution.map(item => ({ path: item.key })), withoutExecution.length));
172
+ const known = new Set(model.tests.map(test => test.key));
173
+ const historical = (model.history?.tests() ?? []).filter(test => !known.has(test.key));
174
+ if (historical.length)
175
+ findings.push(finding('historical-only', 'info', 'The history holds results for these tests, but the report has no current result for them (removed, renamed or not yet re-run). The Tests view does not count them.', historical.map(test => ({ path: test.specPath ? `${test.specPath} ${test.id}` : test.id })), historical.length));
176
+ // Scenarios: outlines, examples, observations.
177
+ const outlines = new Set(model.tests.map(test => (test.labels.outline ? `${test.specPath ?? ''}\u0000${test.labels.outline}` : test.key)));
178
+ let observations = 0, executedObservations = 0, passedObservations = 0;
179
+ for (const test of model.tests)
180
+ for (const outcome of test.outcomes) {
181
+ observations += 1;
182
+ if (executed(outcome.status))
183
+ executedObservations += 1;
184
+ if (outcome.status === 'passed')
185
+ passedObservations += 1;
186
+ }
187
+ const mergedDefined = files - definedGroups.length;
188
+ const steps = [];
189
+ if (mergedDefined)
190
+ steps.push({ code: 'merged-by-mapping', label: `${mergedDefined} spec files are the same promise as another under a declared root mapping`, delta: -mergedDefined });
191
+ if (definedOnly.length)
192
+ steps.push({ code: 'defined-without-results', label: `${definedOnly.length} defined specs have no test`, delta: -definedOnly.length });
193
+ if (resultsOnly.length)
194
+ steps.push({ code: 'results-without-definition', label: `${resultsOnly.length} specs have tests but no definition in the specs the report is laid over`, delta: resultsOnly.length });
195
+ const explanation = [
196
+ `The Specs view lists ${files} spec files; the Tests view groups ${model.tests.length - testsWithoutSpec.length} tests into ${withTests.length} specs.`,
197
+ ...steps.map(step => `${step.delta < 0 ? 'Minus' : 'Plus'} ${Math.abs(step.delta)}: ${step.label}.`),
198
+ ...(testsWithoutSpec.length ? [`${testsWithoutSpec.length} tests name no spec and belong to neither count.`] : []),
199
+ ...(historical.length ? [`${historical.length} more tests exist only in history and are in neither view.`] : []),
200
+ `${model.tests.length} tests have ${observations} current results (one per producer, environment and parameter set); ${executedObservations} were executed and ${passedObservations} passed.`
201
+ ];
202
+ return {
203
+ schemaVersion: CATALOG_HEALTH_VERSION,
204
+ version: model.version,
205
+ generatedAtMs: model.generatedAtMs,
206
+ ok: findings.every(item => item.severity !== 'warning'),
207
+ counts: {
208
+ specs: { files, defined: definedGroups.length, withTests: withTests.length, executed: definedGroups.length - withoutExecution.length, withoutExecution: withoutExecution.length },
209
+ tests: { total: model.tests.length, withSpec: model.tests.length - testsWithoutSpec.length, withoutSpec: testsWithoutSpec.length, passed: model.tests.filter(test => test.status === 'passed').length, historicalOnly: historical.length },
210
+ scenarios: { outlines: outlines.size, examples: model.tests.length, observations, executedObservations, passedObservations }
211
+ },
212
+ reconciliation: { specs: files, testSpecs: withTests.length, steps, testsWithoutSpec: testsWithoutSpec.length, explanation },
213
+ identity: {
214
+ checks: model.tests.length,
215
+ 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.'
217
+ },
218
+ domains,
219
+ roots: ordered.map(mapping => ({ from: mapping.from, to: mapping.to, matched: matched.get(mapping)?.size ?? 0 })),
220
+ aliases,
221
+ findings
222
+ };
223
+ }
224
+ const cell = (text) => text.replaceAll('|', '\\|').replace(/\s+/g, ' ');
225
+ /** The health document as Markdown, for an agent or a terminal. */
226
+ export function healthMarkdown(health) {
227
+ const { counts } = health;
228
+ const lines = [
229
+ '# Catalog health',
230
+ '',
231
+ health.ok ? 'No warnings.' : `${health.findings.filter(item => item.severity === 'warning').length} warnings.`,
232
+ '',
233
+ '## Counts',
234
+ '',
235
+ `- Specs: ${counts.specs.files} files, ${counts.specs.defined} defined, ${counts.specs.withTests} with tests, ${counts.specs.executed} executed, ${counts.specs.withoutExecution} without an executed test.`,
236
+ `- Tests: ${counts.tests.total} (${counts.tests.passed} passed, ${counts.tests.withoutSpec} without a spec, ${counts.tests.historicalOnly} only in history).`,
237
+ `- Scenarios: ${counts.scenarios.outlines} outlines, ${counts.scenarios.examples} examples, ${counts.scenarios.observations} observations (${counts.scenarios.executedObservations} executed, ${counts.scenarios.passedObservations} passed).`,
238
+ '',
239
+ '## Why Specs and Tests differ',
240
+ '',
241
+ ...health.reconciliation.explanation.map(line => `- ${line}`)
242
+ ];
243
+ if (health.aliases.length)
244
+ lines.push('', '## Specs under more than one path', '', ...health.aliases.slice(0, HEALTH_ITEM_LIMIT).map(alias => `- ${cell(alias.path)} <- ${alias.sources.map(cell).join(', ')}`));
245
+ if (health.findings.length) {
246
+ lines.push('', '## Findings');
247
+ for (const item of health.findings) {
248
+ lines.push('', `### ${item.code} (${item.severity}, ${item.count})`, '', item.message, '');
249
+ for (const entry of item.items)
250
+ lines.push(`- ${cell(entry.path)}${entry.candidates?.length ? ` ~ ${entry.candidates.map(candidate => `${cell(candidate.path)} (${candidate.reason})`).join('; ')}` : ''}`);
251
+ if (item.count > item.items.length)
252
+ lines.push(`- ... ${item.count - item.items.length} more`);
253
+ }
254
+ }
255
+ lines.push('', `Identity: ${health.identity.basis}`);
256
+ return `${lines.join('\n')}\n`;
257
+ }
@@ -311,6 +311,12 @@ export declare class TestHistory {
311
311
  get rev(): number;
312
312
  /** The specs the added layers recorded. */
313
313
  get specs(): RecordedSpecs;
314
+ /** Every test the history holds a result for (keyed as `TestSummary.key`), including tests the report no longer has a current result for. */
315
+ tests(): readonly {
316
+ readonly key: string;
317
+ readonly id: string;
318
+ readonly specPath: string | null;
319
+ }[];
314
320
  /** How many layers were added. */
315
321
  get layers(): number;
316
322
  /** Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored. */
@@ -353,6 +359,8 @@ export type CatalogModel = {
353
359
  readonly facets: CatalogFacets;
354
360
  readonly haystacks: ReadonlyMap<string, string>;
355
361
  readonly history: TestHistory | null;
362
+ /** The specs the report is laid over, sorted by path: the given ones, else those the layers recorded. What `catalogHealth` calls defined. Absent on a model built from a server's catalog document. */
363
+ readonly specs?: readonly SpecEntry[];
356
364
  };
357
365
  /** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
358
366
  export declare function haystackOf(test: TestSummary): string;
@@ -80,6 +80,10 @@ export class TestHistory {
80
80
  get specs() {
81
81
  return this.#specs;
82
82
  }
83
+ /** Every test the history holds a result for (keyed as `TestSummary.key`), including tests the report no longer has a current result for. */
84
+ tests() {
85
+ return [...this.#idents.values()].map(({ slot, id, specPath }) => ({ key: slot, id, specPath }));
86
+ }
83
87
  /** How many layers were added. */
84
88
  get layers() {
85
89
  return this.#seen.size;
@@ -462,7 +466,7 @@ export function catalogModel(layers, sources = {}) {
462
466
  branches: facet(tests, test => test.stats.branches),
463
467
  labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
464
468
  };
465
- return { version: sources.version ?? '', generatedAtMs: sources.now ?? Date.now(), tests, byKey, summary, facets, haystacks, history };
469
+ return { version: sources.version ?? '', generatedAtMs: sources.now ?? Date.now(), tests, byKey, summary, facets, haystacks, history, specs: recorded.entries() };
466
470
  }
467
471
  const SORTERS = {
468
472
  spec: (a, b) => compareText(a.spec?.domain ?? '\uffff', b.spec?.domain ?? '\uffff') || compareText(a.spec?.title ?? '\uffff', b.spec?.title ?? '\uffff') || compareText(a.title, b.title),
@@ -1153,6 +1153,85 @@ export declare const TestCatalogSchema: z.ZodObject<{
1153
1153
  live: z.ZodBoolean;
1154
1154
  }, z.core.$loose>>;
1155
1155
  }, z.core.$loose>;
1156
+ /**
1157
+ * `GET /api/reports/tests/health`: why the report's Specs, domains and counts may disagree. Counts keep the denominators
1158
+ * apart (spec files, defined specs, specs with an executed test, tests, outlines, examples, observations), `reconciliation`
1159
+ * walks from one to another, and `findings` name what to fix. It reads the report; it never changes what passed.
1160
+ */
1161
+ export declare const CatalogHealthSchema: z.ZodObject<{
1162
+ schemaVersion: z.ZodLiteral<1>;
1163
+ version: z.ZodString;
1164
+ generatedAtMs: z.ZodNumber;
1165
+ ok: z.ZodBoolean;
1166
+ counts: z.ZodObject<{
1167
+ specs: z.ZodObject<{
1168
+ files: z.ZodNumber;
1169
+ defined: z.ZodNumber;
1170
+ withTests: z.ZodNumber;
1171
+ executed: z.ZodNumber;
1172
+ withoutExecution: z.ZodNumber;
1173
+ }, z.core.$loose>;
1174
+ tests: z.ZodObject<{
1175
+ total: z.ZodNumber;
1176
+ withSpec: z.ZodNumber;
1177
+ withoutSpec: z.ZodNumber;
1178
+ passed: z.ZodNumber;
1179
+ historicalOnly: z.ZodNumber;
1180
+ }, z.core.$loose>;
1181
+ scenarios: z.ZodObject<{
1182
+ outlines: z.ZodNumber;
1183
+ examples: z.ZodNumber;
1184
+ observations: z.ZodNumber;
1185
+ executedObservations: z.ZodNumber;
1186
+ passedObservations: z.ZodNumber;
1187
+ }, z.core.$loose>;
1188
+ }, z.core.$loose>;
1189
+ reconciliation: z.ZodObject<{
1190
+ specs: z.ZodNumber;
1191
+ testSpecs: z.ZodNumber;
1192
+ steps: z.ZodArray<z.ZodObject<{
1193
+ code: z.ZodString;
1194
+ label: z.ZodString;
1195
+ delta: z.ZodNumber;
1196
+ }, z.core.$loose>>;
1197
+ testsWithoutSpec: z.ZodNumber;
1198
+ explanation: z.ZodArray<z.ZodString>;
1199
+ }, z.core.$loose>;
1200
+ identity: z.ZodObject<{
1201
+ checks: z.ZodNumber;
1202
+ fallbackIdentity: z.ZodNumber;
1203
+ basis: z.ZodString;
1204
+ }, z.core.$loose>;
1205
+ domains: z.ZodArray<z.ZodObject<{
1206
+ name: z.ZodString;
1207
+ specs: z.ZodNumber;
1208
+ }, z.core.$loose>>;
1209
+ roots: z.ZodArray<z.ZodObject<{
1210
+ from: z.ZodString;
1211
+ to: z.ZodString;
1212
+ matched: z.ZodNumber;
1213
+ }, z.core.$loose>>;
1214
+ aliases: z.ZodArray<z.ZodObject<{
1215
+ path: z.ZodString;
1216
+ sources: z.ZodArray<z.ZodString>;
1217
+ }, z.core.$loose>>;
1218
+ findings: z.ZodArray<z.ZodObject<{
1219
+ code: z.ZodString;
1220
+ severity: z.ZodEnum<{
1221
+ info: "info";
1222
+ warning: "warning";
1223
+ }>;
1224
+ message: z.ZodString;
1225
+ count: z.ZodNumber;
1226
+ items: z.ZodArray<z.ZodObject<{
1227
+ path: z.ZodString;
1228
+ candidates: z.ZodOptional<z.ZodArray<z.ZodObject<{
1229
+ path: z.ZodString;
1230
+ reason: z.ZodString;
1231
+ }, z.core.$loose>>>;
1232
+ }, z.core.$loose>>;
1233
+ }, z.core.$loose>>;
1234
+ }, z.core.$loose>;
1156
1235
  /** `GET /api/reports/tests/history`: one test and every result recorded for it (at most 100), newest first. */
1157
1236
  export declare const TestHistorySchema: z.ZodObject<{
1158
1237
  schemaVersion: z.ZodLiteral<1>;
@@ -275,6 +275,56 @@ export const TestCatalogSchema = z.looseObject({
275
275
  limit: z.number().int().nonnegative(),
276
276
  tests: z.array(TestSummarySchema)
277
277
  });
278
+ const nonneg = z.number().int().nonnegative();
279
+ const HealthItemSchema = z.looseObject({
280
+ path: z.string(),
281
+ candidates: z.array(z.looseObject({ path: z.string(), reason: z.string() })).optional().describe('Other paths it could be the same promise as. Atlas has not merged them')
282
+ });
283
+ /**
284
+ * `GET /api/reports/tests/health`: why the report's Specs, domains and counts may disagree. Counts keep the denominators
285
+ * apart (spec files, defined specs, specs with an executed test, tests, outlines, examples, observations), `reconciliation`
286
+ * walks from one to another, and `findings` name what to fix. It reads the report; it never changes what passed.
287
+ */
288
+ export const CatalogHealthSchema = z.looseObject({
289
+ schemaVersion: z.literal(1),
290
+ version: z.string().describe('Changes whenever the report or its history does'),
291
+ generatedAtMs: z.number(),
292
+ ok: z.boolean().describe('True when there is no warning-severity finding'),
293
+ counts: z.looseObject({
294
+ specs: z.looseObject({
295
+ files: nonneg.describe('Spec files the report is laid over, before any root mapping'),
296
+ defined: nonneg.describe('Logical specs: files joined by a declared root mapping count once'),
297
+ withTests: nonneg.describe('Logical specs, defined or not, that have at least one test'),
298
+ executed: nonneg.describe('Defined logical specs with a test that passed or failed'),
299
+ withoutExecution: nonneg.describe('Defined logical specs with no passed or failed test: the defined coverage gaps')
300
+ }),
301
+ tests: z.looseObject({ total: nonneg, withSpec: nonneg, withoutSpec: nonneg, passed: nonneg.describe('Tests whose current status is passed'), historicalOnly: nonneg.describe('Tests only the history holds a result for') }),
302
+ scenarios: z.looseObject({
303
+ outlines: nonneg.describe('Tests sharing the label `outline` count once; a test without it is its own outline'),
304
+ examples: nonneg.describe('Tests (check identities), however many environments ran them'),
305
+ observations: nonneg.describe('Current results per producer, environment and parameter set'),
306
+ executedObservations: nonneg, passedObservations: nonneg
307
+ })
308
+ }),
309
+ reconciliation: z.looseObject({
310
+ specs: nonneg.describe('What the Specs view counts: spec files'),
311
+ testSpecs: nonneg.describe('What the Tests view groups by: logical specs with at least one test'),
312
+ steps: z.array(z.looseObject({ code: z.string(), label: z.string(), delta: z.number().int() })).describe('From `specs` to `testSpecs`: each step adds its `delta`'),
313
+ testsWithoutSpec: nonneg,
314
+ explanation: z.array(z.string())
315
+ }),
316
+ identity: z.looseObject({ checks: nonneg, fallbackIdentity: nonneg.describe('Checks whose id equals their title'), basis: z.string() }),
317
+ domains: z.array(z.looseObject({ name: z.string(), specs: nonneg })).describe('Specs per domain, most first'),
318
+ roots: z.array(z.looseObject({ from: z.string(), to: z.string(), matched: nonneg.describe('Distinct paths the mapping changed') })),
319
+ 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'),
320
+ findings: z.array(z.looseObject({
321
+ code: z.string().describe('An open set: treat a code you do not know as informational'),
322
+ severity: z.enum(['warning', 'info']),
323
+ message: z.string(),
324
+ count: nonneg,
325
+ items: z.array(HealthItemSchema).describe('At most 25; `count` is the whole')
326
+ }))
327
+ });
278
328
  /** `GET /api/reports/tests/history`: one test and every result recorded for it (at most 100), newest first. */
279
329
  export const TestHistorySchema = z.looseObject({
280
330
  schemaVersion: z.literal(1),
@@ -25,6 +25,7 @@ import { reportStatus } from '../../reports/src/report-status.js';
25
25
  import { ReportIndex } from '../../reports/src/report-index.js';
26
26
  import { listRunSummaries, readRunFile } from '../../reports/src/run-bundle.js';
27
27
  import { latestSpecSet, readSpecBody } from '../../reports/src/spec-set.js';
28
+ import { catalogHealth, healthMarkdown, parseRootMappings } from '../../reports/src/catalog-health.js';
28
29
  import { catalogMarkdown, catalogModel, historyMarkdown, parseTestQuery, runDetail, runList, runMarkdown, runsMarkdown, testCatalog, testHistory } from '../../reports/src/test-catalog.js';
29
30
  import { llmsTxt } from './llms.js';
30
31
  import { openApiDocument } from './openapi.js';
@@ -399,6 +400,14 @@ function createProjectApplication(options) {
399
400
  const document = () => testCatalog(found, query);
400
401
  return answer(c, found.version, document, () => catalogMarkdown(document()), `tests|${new URL(c.req.url).search}|${wantsMarkdown(c)}`);
401
402
  });
403
+ app.get('/api/reports/tests/health', async (c) => {
404
+ if (!index.ready)
405
+ return notReady(c);
406
+ const found = await catalog();
407
+ const params = new URL(c.req.url).searchParams;
408
+ const document = () => catalogHealth(found, parseRootMappings(params));
409
+ return answer(c, found.version, document, () => healthMarkdown(document()), `health|${params.getAll('root').join('\u0001')}|${wantsMarkdown(c)}`);
410
+ });
402
411
  app.get('/api/reports/tests/history', async (c) => {
403
412
  if (!index.ready)
404
413
  return notReady(c);
@@ -68,6 +68,7 @@ Every endpoint below answers JSON, or Markdown with \`format=markdown\`. Prefix
68
68
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?format=markdown&limit=50" # summary, environments, domains, failing, flaky, slowest, then the tests
69
69
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?status=failed,flaky&environment=desktop-web" # filter: q, status, environment, producer, domain, spec, tag, run; sort, offset, limit
70
70
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/history?key=<url-encoded test key>" # one test: every recorded result, durations, branches, CI runs
71
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/health?format=markdown" # why Specs, domains and counts disagree; add root=<from>><to> to declare a path-root mapping
71
72
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs?format=markdown" # CI runs, newest first, with their results by status
72
73
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs/detail?key=<url-encoded run key>" # every test one run recorded
73
74
  \`\`\`