@crvouga/atlas 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/reports/src/catalog-health.d.ts +15 -2
  3. package/dist/reports/src/catalog-health.js +23 -2
  4. package/dist/reports/src/test-catalog.d.ts +13 -0
  5. package/dist/reports/src/test-catalog.js +21 -5
  6. package/dist/schema/src/api.d.ts +35 -0
  7. package/dist/schema/src/api.js +14 -3
  8. package/dist/schema/src/server-config.d.ts +3 -0
  9. package/dist/schema/src/server-config.js +5 -0
  10. package/dist/server/src/app.d.ts +2 -0
  11. package/dist/server/src/app.js +6 -2
  12. package/dist/server/src/config.js +4 -0
  13. package/dist/server/src/main.js +1 -0
  14. package/dist/server/src/openapi.js +2 -2
  15. package/docs/extending.md +1 -1
  16. package/docs/reference/http-api.md +4 -4
  17. package/docs/reference/reports.md +41 -2
  18. package/docs/reference/server.md +2 -0
  19. package/docs/server.md +11 -2
  20. package/docs/ui.md +28 -1
  21. package/llms-full.txt +44 -8
  22. package/package.json +1 -1
  23. package/packages/schema/schemas/catalog-health.schema.json +20 -0
  24. package/packages/schema/schemas/server-config.schema.json +17 -0
  25. package/packages/schema/schemas/test-catalog.schema.json +26 -0
  26. package/packages/schema/schemas/test-history.schema.json +40 -0
  27. package/packages/server/openapi.json +90 -4
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +46 -20
  29. package/packages/visualizer/src/evidence/applicability.test.ts +68 -1
  30. package/packages/visualizer/src/evidence/applicability.ts +121 -26
  31. package/packages/visualizer/src/explorer/Explorer.tsx +105 -10
  32. package/packages/visualizer/src/explorer/HealthView.tsx +402 -0
  33. package/packages/visualizer/src/explorer/TestDetail.tsx +14 -1
  34. package/packages/visualizer/src/explorer/applicability.ts +43 -0
  35. package/packages/visualizer/src/explorer/catalog.tsx +33 -0
  36. package/packages/visualizer/src/explorer/search.ts +20 -7
@@ -1,5 +1,5 @@
1
1
  import { SpecLogo } from '../components/SpecLogo';
2
- import { IconVideo, IconX } from '@tabler/icons-react';
2
+ import { IconAlertTriangle, IconVideo, IconX } from '@tabler/icons-react';
3
3
  import {
4
4
  Accordion,
5
5
  Alert,
@@ -44,6 +44,8 @@ import {
44
44
  } from '@crvouga/atlas/reports/test-catalog';
45
45
  import { Link } from '@tanstack/react-router';
46
46
  import { useDeferredValue, useEffect, useMemo, useState } from 'react';
47
+ import { attentionRank, hasRelease, needsAttention, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
48
+ import { ApplicabilityControls, VerdictBadge } from '../evidence/applicability-view';
47
49
  import { staticEvidence } from '../evidence/api';
48
50
  import { useEvidence } from '../evidence/evidence-context';
49
51
  import { ago, plural, useNow } from '../evidence/format';
@@ -53,12 +55,14 @@ import { behaviorDocs, behaviorId, describeHit, testHasVideo } from '../find/beh
53
55
  import { useDocSearch } from '../find/doc-search';
54
56
  import { hasFilters, passesFilters, withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
55
57
  import type { FindDoc, Ranked } from '../find/rank';
58
+ import { assessTests, releaseOf, useExplorerApplicability } from './applicability';
56
59
  import { useRunDetail, useTestCatalog } from './catalog';
57
60
  import { groupTests, tally, type TestGroupView } from './groups';
58
61
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
59
62
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
60
63
  import { ArtifactsView } from './ArtifactsView';
61
64
  import { CompareView } from './CompareView';
65
+ import { HealthView } from './HealthView';
62
66
  import { CollectionsView } from './collections';
63
67
  import { RunsView } from './RunsView';
64
68
  import { SearchNotes, TypedFilters } from './search-notes';
@@ -79,6 +83,7 @@ const GROUP_LABEL: Record<TestGroup, string> = {
79
83
  };
80
84
  const SORT_LABEL: Record<ExplorerSort, string> = {
81
85
  relevance: 'Best match',
86
+ evidence: 'Stale evidence first',
82
87
  spec: 'Spec',
83
88
  title: 'Title',
84
89
  recent: 'Most recent',
@@ -118,8 +123,12 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
118
123
  const list = useMemo(() => [...docs.values()], [docs]);
119
124
  const found = useDocSearch(list, q);
120
125
  const sort = sortOf(search);
126
+ const { release: releaseBuild, releaseSha, releasePlatform } = search;
127
+ const release = useMemo(() => releaseOf({ release: releaseBuild, releaseSha, releasePlatform }), [releaseBuild, releaseSha, releasePlatform]);
128
+ // Every test's evidence read against its promise and the named release: a reading, never a status.
129
+ const assessments = useMemo(() => (model ? assessTests(model.tests, release) : null), [model?.tests, release]);
121
130
  const listing = useMemo(() => {
122
- if (!model) return null;
131
+ if (!model || !assessments) return null;
123
132
  const query: TestQuery = { ...queryOf(search), q: undefined };
124
133
  // `has:video` typed in the box and the Has video button are the same filter.
125
134
  const operator = { ...found.parsed.filters, video: found.parsed.filters.video || search.video === true };
@@ -128,17 +137,25 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
128
137
  const passes = (test: TestSummary, omit: ReadonlySet<FilterKey>) =>
129
138
  (!hits || hits.has(behaviorId(test.key))) && (!structured || passesFilters(docs.get(test.key)?.facets, operator, omit));
130
139
  let shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => (!inRun || inRun.has(test.key)) && passes(test, NO_STRUCTURE));
140
+ const attention = (test: TestSummary) => needsAttention(assessments.get(test.key)!);
141
+ if (search.evidence === 'stale') shown = shown.filter(attention);
131
142
  if (sort === 'relevance' && hits) {
132
143
  const order = new Map([...hits.keys()].map((id, at) => [id, at] as const));
133
144
  shown = [...shown].sort((a, b) => (order.get(behaviorId(a.key)) ?? 0) - (order.get(behaviorId(b.key)) ?? 0));
134
145
  }
146
+ // Stale evidence first; the order of the spec list is kept among equals.
147
+ if (sort === 'evidence') shown = [...shown].sort((a, b) => attentionRank(assessments.get(a.key)!) - attentionRank(assessments.get(b.key)!));
135
148
  // The strip and the status and environment counts describe what the other filters leave.
136
149
  const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun) && passes(test, WITHOUT_STATUS_AND_ENVIRONMENT));
137
150
  const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }) && passes(test, WITHOUT_STATUS));
138
151
  const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }) && passes(test, WITHOUT_ENVIRONMENT));
139
152
  const videoScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, WITHOUT_VIDEO));
153
+ const evidenceScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, NO_STRUCTURE));
140
154
  return {
141
155
  shown,
156
+ assessments,
157
+ release,
158
+ stale: evidenceScope.filter(attention).length,
142
159
  withVideo: videoScope.filter((test) => docs.get(test.key)?.facets?.video ?? testHasVideo(test)).length,
143
160
  scope,
144
161
  byEnvironment,
@@ -149,7 +166,7 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
149
166
  runPending: !!search.inRun && run.isPending,
150
167
  runMissing: !!search.inRun && run.isSuccess && run.data === null
151
168
  };
152
- }, [model, search, found, docs, sort, inRun, run.isPending, run.isSuccess, run.data]);
169
+ }, [model, search, found, docs, sort, inRun, assessments, release, run.isPending, run.isSuccess, run.data]);
153
170
  return listing;
154
171
  }
155
172
  /** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
@@ -170,7 +187,7 @@ export function Explorer() {
170
187
  const model = catalog.model;
171
188
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
172
189
  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';
190
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare' && search.tab !== 'health';
174
191
  return (
175
192
  <Container size="xl" py="xl">
176
193
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -181,7 +198,7 @@ export function Explorer() {
181
198
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
182
199
  <Tabs
183
200
  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 })}
201
+ 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
202
  >
186
203
  <Tabs.List aria-label="Explorer view">
187
204
  <Tabs.Tab
@@ -206,6 +223,7 @@ export function Explorer() {
206
223
  </Tabs.Tab>
207
224
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
208
225
  <Tabs.Tab value="compare">Compare</Tabs.Tab>
226
+ <Tabs.Tab value="health">Catalog health</Tabs.Tab>
209
227
  {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
210
228
  </Tabs.List>
211
229
  </Tabs>
@@ -243,6 +261,9 @@ export function Explorer() {
243
261
  </List.Item>
244
262
  ))}
245
263
  </List>
264
+ <Anchor mt="xs" display="inline-block" size="sm" renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'health', test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
265
+ Why do these counts differ from the Tests?
266
+ </Anchor>
246
267
  </Box>
247
268
  )}
248
269
  {!model && !evidence.unavailable && (
@@ -273,6 +294,8 @@ export function Explorer() {
273
294
  <ArtifactsView />
274
295
  ) : search.tab === 'compare' ? (
275
296
  <CompareView />
297
+ ) : search.tab === 'health' ? (
298
+ <HealthView />
276
299
  ) : search.tab === 'collections' ? (
277
300
  <CollectionsView />
278
301
  ) : empty ? null : (
@@ -294,7 +317,7 @@ export function Explorer() {
294
317
  That run is not in the report any more.
295
318
  </Alert>
296
319
  )}
297
- <TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} />
320
+ <TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} assessments={listing.assessments} release={listing.release} />
298
321
  </>
299
322
  ))}
300
323
  </Box>
@@ -673,6 +696,19 @@ function Toolbar({
673
696
  {listing.withVideo.toLocaleString()}
674
697
  </Text>
675
698
  </Button>
699
+ <Button
700
+ type="button"
701
+ aria-pressed={search.evidence === 'stale'}
702
+ aria-label={`Stale or unverified evidence: ${plural(listing.stale, 'test', 'tests')}`}
703
+ onClick={() => go({ evidence: search.evidence ? undefined : 'stale' })}
704
+ variant={search.evidence === 'stale' ? 'light' : 'subtle'}
705
+ leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
706
+ >
707
+ Stale or unverified{' '}
708
+ <Text span inherit>
709
+ {listing.stale.toLocaleString()}
710
+ </Text>
711
+ </Button>
676
712
  {model.facets.producers.length > 1 && (
677
713
  <Box component="label">
678
714
  <VisuallyHidden>Producer</VisuallyHidden>
@@ -781,7 +817,8 @@ function Toolbar({
781
817
  artifactTag: undefined,
782
818
  inherited: undefined,
783
819
  inRun: undefined,
784
- video: undefined
820
+ video: undefined,
821
+ evidence: undefined
785
822
  });
786
823
  }}
787
824
  variant="subtle"
@@ -791,10 +828,47 @@ function Toolbar({
791
828
  )}
792
829
  </Flex>
793
830
  <SearchNotes issues={listing.parsed.issues} semantic={listing.semantic} />
831
+ <ReleaseReading />
794
832
  </Box>
795
833
  );
796
834
  }
797
- function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number; hits: ReadonlyMap<string, Ranked> | null; sort: ExplorerSort }) {
835
+ /**
836
+ * Names the release the evidence of the tests is read against (build, commit and platform). Folded away until a release is
837
+ * named, because most readers only ask whether evidence is stale.
838
+ */
839
+ function ReleaseReading() {
840
+ const applicability = useExplorerApplicability();
841
+ const named = hasRelease(applicability.release) || !!applicability.release.platform;
842
+ return (
843
+ <Accordion variant="separated" defaultValue={named ? 'release' : null} mt="sm">
844
+ <Accordion.Item value="release">
845
+ <Accordion.Control>Release the evidence is read against</Accordion.Control>
846
+ <Accordion.Panel>
847
+ <ApplicabilityControls state={applicability} filter={false} />
848
+ </Accordion.Panel>
849
+ </Accordion.Item>
850
+ </Accordion>
851
+ );
852
+ }
853
+ function TestList({
854
+ tests,
855
+ search,
856
+ total,
857
+ hits,
858
+ sort,
859
+ assessments,
860
+ release
861
+ }: {
862
+ tests: readonly TestSummary[];
863
+ search: ExplorerSearch;
864
+ total: number;
865
+ hits: ReadonlyMap<string, Ranked> | null;
866
+ sort: ExplorerSort;
867
+ assessments: ReadonlyMap<string, Assessment>;
868
+ release: ReleaseIdentity;
869
+ }) {
870
+ // A reading is shown on every row only when the reader asked about evidence; otherwise only evidence known to be stale.
871
+ const asked = search.evidence === 'stale' || sort === 'evidence' || hasRelease(release);
798
872
  const by = search.group ?? 'spec';
799
873
  const groups = useMemo(() => groupTests(tests, by, sort === 'spec'), [tests, by, sort]);
800
874
  const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
@@ -857,6 +931,8 @@ function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSu
857
931
  open={isOpen(group)}
858
932
  selected={search.test}
859
933
  hits={hits}
934
+ assessments={assessments}
935
+ asked={asked}
860
936
  onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
861
937
  />
862
938
  </Stack>
@@ -871,6 +947,8 @@ function TestGroupSection({
871
947
  open,
872
948
  selected,
873
949
  hits,
950
+ assessments,
951
+ asked,
874
952
  onToggle
875
953
  }: {
876
954
  group: TestGroupView;
@@ -878,6 +956,8 @@ function TestGroupSection({
878
956
  open: boolean;
879
957
  selected: string | undefined;
880
958
  hits: ReadonlyMap<string, Ranked> | null;
959
+ assessments: ReadonlyMap<string, Assessment>;
960
+ asked: boolean;
881
961
  onToggle: () => void;
882
962
  }) {
883
963
  const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
@@ -885,7 +965,7 @@ function TestGroupSection({
885
965
  const rows = (
886
966
  <List spacing="xs" listStyleType="none">
887
967
  {group.tests.slice(0, shown).map((test) => (
888
- <TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} />
968
+ <TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} assessment={assessments.get(test.key)} asked={asked} />
889
969
  ))}
890
970
  {group.tests.length > shown && (
891
971
  <List.Item>
@@ -930,7 +1010,21 @@ function TestGroupSection({
930
1010
  </Box>
931
1011
  );
932
1012
  }
933
- function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: boolean; now: number; hit: Ranked | undefined }) {
1013
+ function TestRow({
1014
+ test,
1015
+ selected,
1016
+ now,
1017
+ hit,
1018
+ assessment,
1019
+ asked
1020
+ }: {
1021
+ test: TestSummary;
1022
+ selected: boolean;
1023
+ now: number;
1024
+ hit: Ranked | undefined;
1025
+ assessment: Assessment | undefined;
1026
+ asked: boolean;
1027
+ }) {
934
1028
  const why = hit ? describeHit(hit) : '';
935
1029
  const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
936
1030
  return (
@@ -959,6 +1053,7 @@ function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: bo
959
1053
  <Group gap="xs">
960
1054
  <EnvironmentChips test={test} />
961
1055
  <Badges test={test} />
1056
+ {assessment && (assessment.verdict === 'stale' || (asked && assessment.verdict !== 'applies')) && <VerdictBadge verdict={assessment.verdict} />}
962
1057
  </Group>
963
1058
  {failure && (
964
1059
  <Text size="xs" c="red.7" lineClamp={2}>