@crvouga/atlas 0.17.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/garbage.d.ts +39 -0
  6. package/dist/reports/src/garbage.js +82 -0
  7. package/dist/reports/src/report-index.d.ts +6 -0
  8. package/dist/reports/src/report-index.js +14 -0
  9. package/dist/schema/src/index.d.ts +1 -0
  10. package/dist/schema/src/index.js +1 -0
  11. package/dist/schema/src/server-config.d.ts +7 -49
  12. package/dist/schema/src/server-config.js +8 -21
  13. package/dist/schema/src/user-state.d.ts +115 -0
  14. package/dist/schema/src/user-state.js +54 -0
  15. package/dist/server/src/app.d.ts +16 -2
  16. package/dist/server/src/app.js +62 -0
  17. package/dist/server/src/config.d.ts +16 -21
  18. package/dist/server/src/config.js +48 -57
  19. package/dist/server/src/dev.d.ts +1 -1
  20. package/dist/server/src/dev.js +20 -7
  21. package/dist/server/src/index.d.ts +5 -1
  22. package/dist/server/src/index.js +5 -1
  23. package/dist/server/src/main.d.ts +23 -7
  24. package/dist/server/src/main.js +72 -26
  25. package/dist/server/src/openapi.js +24 -5
  26. package/dist/server/src/projects.js +89 -14
  27. package/dist/server/src/settings.d.ts +23 -7
  28. package/dist/server/src/settings.js +50 -60
  29. package/dist/server/src/storage.d.ts +68 -0
  30. package/dist/server/src/storage.js +125 -0
  31. package/dist/server/src/user-state.d.ts +31 -0
  32. package/dist/server/src/user-state.js +74 -0
  33. package/docs/agents.md +1 -1
  34. package/docs/deploying.md +38 -39
  35. package/docs/reference/cli.md +12 -7
  36. package/docs/reference/http-api.md +30 -2
  37. package/docs/reference/reports.md +71 -0
  38. package/docs/reference/schema.md +102 -16
  39. package/docs/reference/server.md +263 -41
  40. package/docs/report-backends.md +0 -144
  41. package/docs/report-contract.md +1 -1
  42. package/docs/runners.md +1 -1
  43. package/docs/server.md +141 -36
  44. package/docs/statecharts.md +1 -1
  45. package/docs/ui.md +8 -1
  46. package/docs/workspaces.md +5 -2
  47. package/llms-full.txt +238 -234
  48. package/llms.txt +1 -1
  49. package/package.json +2 -2
  50. package/packages/reports/STORAGE.md +1 -1
  51. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  52. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  53. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  54. package/packages/schema/schemas/server-config.schema.json +0 -41
  55. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  56. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  57. package/packages/schema/schemas/user-state.schema.json +140 -0
  58. package/packages/server/README.md +11 -6
  59. package/packages/server/openapi.json +510 -126
  60. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  61. package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
  62. package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
  63. package/packages/visualizer/src/evidence/load-state.tsx +65 -0
  64. package/packages/visualizer/src/evidence/overview.tsx +4 -6
  65. package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
  66. package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
  67. package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
  68. package/packages/visualizer/src/find/Finder.tsx +45 -7
  69. package/packages/visualizer/src/spec-kind.test.ts +14 -1
  70. package/packages/visualizer/src/spec-kind.ts +28 -2
@@ -1,11 +1,13 @@
1
- import { Alert, Button, Container, Text, Title } from '@mantine/core';
2
- import { useQuery } from '@tanstack/react-query';
3
- import { useNavigate, useParams, useSearch } from '@tanstack/react-router';
1
+ import { Alert, Anchor, Button, Container, Group, List, Stack, Text, Title } from '@mantine/core';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router';
4
4
  import { lazy, Suspense } from 'react';
5
5
  import { keys, specKey, useAdapter } from '../data/queries';
6
- import { outlinePath, specFile, specKind } from '../spec-kind';
6
+ import { outlinePath, resolveSpec, similarSpecs, specFile, specKind, type SpecKind } from '../spec-kind';
7
7
  import type { SelectCheck } from './check-detail';
8
8
  import { useEvidence } from './evidence-context';
9
+ import { isMissing, useStalled } from './load-state';
10
+ import { SpecHeader } from './spec-header';
9
11
  import { Overview } from './overview';
10
12
  // Each kind of spec has a page with a parser of its own (Gherkin, Quint, DMN); a page loads only the one it shows.
11
13
  const DocumentPage = lazy(() => import('./document-page').then((m) => ({ default: m.DocumentPage })));
@@ -27,6 +29,63 @@ function Loading({ children }: { children: React.ReactNode }) {
27
29
  </Suspense>
28
30
  );
29
31
  }
32
+ /** What a spec that cannot be shown still says: where it was looked for, what is recorded for it, and the ways on. */
33
+ function SpecUnavailable({
34
+ kind,
35
+ file,
36
+ missing,
37
+ detail,
38
+ retry,
39
+ run
40
+ }: {
41
+ kind: SpecKind;
42
+ file: string;
43
+ missing: boolean;
44
+ detail: string;
45
+ retry: () => void;
46
+ run: string | undefined;
47
+ }) {
48
+ const evidence = useEvidence();
49
+ const listed = evidence.files.filter(kind.accepts);
50
+ const similar = similarSpecs(file, listed);
51
+ const entry = evidence.view.specs.get(outlinePath(file));
52
+ return (
53
+ <Container component="article" size="xl" w="100%" py="xl">
54
+ <SpecHeader title={file.split('/').at(-1)!} file={file} kind={kind} entry={entry}>
55
+ <Alert role={missing ? 'status' : 'alert'} color={missing ? 'orange' : 'red'} my="sm" title={missing ? `This ${kind.one} is not available` : `The ${kind.one} could not be read`}>
56
+ <Stack gap="sm">
57
+ <Text component="div">
58
+ {missing ? `specs/${file} is not in this spec set. ` : ''}
59
+ {detail}
60
+ {evidence.filesPending || evidence.filesError ? '' : ` ${listed.length} ${listed.length === 1 ? kind.one : kind.many} are listed.`}
61
+ {entry && entry.slots.length > 0 ? ' Results recorded against it are shown above.' : ''}
62
+ </Text>
63
+ {similar.length > 0 && (
64
+ <>
65
+ <Text component="div">Did you mean:</Text>
66
+ <List listStyleType="none">
67
+ {similar.map((candidate) => (
68
+ <List.Item key={candidate}>
69
+ <Anchor renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run, spec: candidate }} />}>{candidate}</Anchor>
70
+ </List.Item>
71
+ ))}
72
+ </List>
73
+ </>
74
+ )}
75
+ <Group gap="sm">
76
+ <Button type="button" onClick={retry} variant="light">
77
+ Try again
78
+ </Button>
79
+ <Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run }} />}>
80
+ Back to {kind.label}
81
+ </Button>
82
+ </Group>
83
+ </Stack>
84
+ </Alert>
85
+ </SpecHeader>
86
+ </Container>
87
+ );
88
+ }
30
89
  /** One spec tab: its overview, one of its documents, or a recording opened from that document. */
31
90
  export function SpecPage() {
32
91
  const params = useParams({ from: '/specs/$kind' });
@@ -35,7 +94,8 @@ export function SpecPage() {
35
94
  const adapter = useAdapter();
36
95
  const evidence = useEvidence();
37
96
  const kind = specKind(params.kind);
38
- const file = search.spec ? specFile(search.spec) : '';
97
+ // A link may name a spec by another form of its path (with the folder, in other letter case): the listed one is shown.
98
+ const file = search.spec ? resolveSpec(specFile(search.spec), evidence.files) : '';
39
99
  const readable = kind !== null && file.length > 0 && kind.accepts(file);
40
100
  const text = useQuery({
41
101
  queryKey: keys.specFile(specKey(adapter), file),
@@ -43,6 +103,12 @@ export function SpecPage() {
43
103
  enabled: readable,
44
104
  retry: false
45
105
  });
106
+ const stalled = useStalled(text.isLoading, file);
107
+ const client = useQueryClient();
108
+ // A read that has not answered is dropped before asking again; asking again beside it would only wait for the same answer.
109
+ const retryText = () => {
110
+ void client.cancelQueries({ queryKey: keys.specFile(specKey(adapter), file) }).then(() => text.refetch());
111
+ };
46
112
  if (!kind) {
47
113
  return (
48
114
  <Container component="article" size="xl" w="100%" py="xl">
@@ -101,24 +167,41 @@ export function SpecPage() {
101
167
  </Loading>
102
168
  );
103
169
  }
104
- if (text.isPending)
170
+ if (text.isPending || (text.isError && evidence.filesPending))
105
171
  return (
106
172
  <Container component="article" size="xl" w="100%" py="xl">
107
173
  <Text role="status" component="div" c="dimmed">
108
- Loading the {kind.one}…
174
+ Loading specs/{file}…
109
175
  </Text>
176
+ {stalled && (
177
+ <Alert role="status" color="orange" my="sm" title={`Reading the ${kind.one} is taking longer than expected`}>
178
+ <Group gap="sm">
179
+ <Text span inherit>
180
+ specs/{file} has not arrived yet. Recorded results for other specs are not affected.
181
+ </Text>
182
+ <Button type="button" onClick={retryText} variant="light">
183
+ Try again
184
+ </Button>
185
+ <Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run: search.run }} />}>
186
+ Back to {kind.label}
187
+ </Button>
188
+ </Group>
189
+ </Alert>
190
+ )}
110
191
  </Container>
111
192
  );
112
193
  if (text.isError) {
194
+ const listedElsewhere = !evidence.filesPending && !evidence.filesError && !evidence.files.includes(file);
195
+ const missing = isMissing(text.error) || listedElsewhere;
113
196
  return (
114
- <Container component="article" size="xl" w="100%" py="xl">
115
- <Alert role="alert" color={'red'} my="sm">
116
- The {kind.one} could not be read.{' '}
117
- <Button type="button" onClick={() => void text.refetch()} variant="subtle">
118
- Try again
119
- </Button>
120
- </Alert>
121
- </Container>
197
+ <SpecUnavailable
198
+ kind={kind}
199
+ file={file}
200
+ missing={missing}
201
+ detail={text.error instanceof Error ? text.error.message : ''}
202
+ retry={retryText}
203
+ run={search.run}
204
+ />
122
205
  );
123
206
  }
124
207
  const page = { file, text: text.data, kind, entry, selection: { check: search.check, layer: search.layer }, select };
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboa
6
6
  import { useAtlas } from '../app/atlas-context';
7
7
  import { keys, specKey, useAdapter } from '../data/queries';
8
8
  import { useEvidence } from '../evidence/evidence-context';
9
+ import { plural } from '../evidence/format';
9
10
  import { StatusMark } from '../evidence/marks';
10
11
  import { kindOfFile } from '../spec-kind';
11
12
  import { corpusFrom } from './corpus';
@@ -41,15 +42,29 @@ function isTypingTarget(target: EventTarget | null) {
41
42
  const tag = target.tagName;
42
43
  return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
43
44
  }
44
- function useSpecTexts(files: readonly string[]) {
45
+ /**
46
+ * Reads the text of every spec for search, a few at a time, once `enabled`. Each file lands as it arrives, so a search
47
+ * can use what is read so far; `read` of `total` say how far it is, and `failed` how many could not be read at all.
48
+ */
49
+ function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
45
50
  const adapter = useAdapter();
46
51
  const client = useQueryClient();
47
52
  const source = specKey(adapter);
48
53
  const cache = useRef(new Map<string, string>());
54
+ const unreadable = useRef(new Set<string>());
49
55
  const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
50
56
  const [pending, setPending] = useState(false);
57
+ const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
51
58
  const key = files.join('\n');
59
+ // Opening the finder is another chance for the files that could not be read.
52
60
  useEffect(() => {
61
+ if (!open || unreadable.current.size === 0) return;
62
+ for (const file of unreadable.current) cache.current.delete(file);
63
+ unreadable.current.clear();
64
+ setFailedFiles(new Set());
65
+ }, [open]);
66
+ useEffect(() => {
67
+ if (!enabled) return;
53
68
  const wanted = key.length === 0 ? [] : key.split('\n');
54
69
  let cancelled = false;
55
70
  const missing = wanted.filter((file) => !cache.current.has(file));
@@ -65,13 +80,15 @@ function useSpecTexts(files: readonly string[]) {
65
80
  if (file === undefined || cancelled) return;
66
81
  let text = '';
67
82
  try {
68
- text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file) });
83
+ text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
69
84
  } catch {
70
85
  text = '';
86
+ unreadable.current.add(file);
71
87
  }
72
88
  if (cancelled) return;
73
89
  cache.current.set(file, text);
74
90
  setTexts(new Map(cache.current));
91
+ if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
75
92
  }
76
93
  };
77
94
  void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
@@ -80,8 +97,10 @@ function useSpecTexts(files: readonly string[]) {
80
97
  return () => {
81
98
  cancelled = true;
82
99
  };
83
- }, [adapter, client, key, source]);
84
- return { texts, pending };
100
+ }, [adapter, client, enabled, key, source]);
101
+ const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
102
+ const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
103
+ return { texts, pending, total: files.length, read, failed };
85
104
  }
86
105
  /** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
87
106
  const vectorCache = new Map<string, Float32Array>();
@@ -101,12 +120,25 @@ export function Finder() {
101
120
  const navigate = useNavigate();
102
121
  const routeSearch = useSearch({ strict: false });
103
122
  const readable = useMemo(() => evidence.files.filter((file) => kindOfFile(file)), [evidence.files]);
104
- const { texts, pending } = useSpecTexts(readable);
123
+ // Reading every spec's text is for search, and a focused page needs its own spec first: start only once search is wanted.
124
+ const [wanted, setWanted] = useState(false);
125
+ useEffect(() => {
126
+ if (open) setWanted(true);
127
+ }, [open]);
128
+ const { texts, pending, total, read, failed } = useSpecTexts(readable, wanted, open);
105
129
  const docs = useMemo(
106
130
  () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
107
131
  [view, evidence.files, evidence.titleOf, evidence.view, texts]
108
132
  );
109
133
  const index = useMemo(() => indexCorpus(docs), [docs]);
134
+ // What search has not covered yet, so an empty answer is not read as "no such behavior".
135
+ const unindexed = evidence.filesPending
136
+ ? 'The spec list is still loading'
137
+ : pending
138
+ ? `${total - read} of ${total} specs are still being read`
139
+ : !evidence.ready && !evidence.unavailable
140
+ ? 'Recorded results are still loading'
141
+ : '';
110
142
  const [query, setQuery] = useState('');
111
143
  const store = finderStore;
112
144
  const spotlightState = useSpotlight(store);
@@ -254,7 +286,13 @@ export function Finder() {
254
286
  <Spotlight.ActionsList id="atlas-finder-actions" role="listbox" aria-label="Flows, specs and runs">
255
287
  {shown.length === 0 && (
256
288
  <Spotlight.Empty>
257
- {query.trim() ? 'Nothing close to that.' : 'Type what someone does. For example, add a todo.'}
289
+ {!query.trim()
290
+ ? 'Type what someone does. For example, add a todo.'
291
+ : unindexed
292
+ ? `No match yet. ${unindexed}, so this is not the final answer.`
293
+ : failed > 0
294
+ ? `Nothing close to that. ${plural(failed, 'spec', 'specs')} could not be read, so search may miss ${failed === 1 ? 'it' : 'them'}.`
295
+ : 'Nothing close to that.'}
258
296
  </Spotlight.Empty>
259
297
  )}
260
298
  {FIND_GROUPS.map((group) => {
@@ -285,7 +323,7 @@ export function Finder() {
285
323
  <Spotlight.Footer>
286
324
  <Group gap="md" justify="space-between">
287
325
  <Text size="xs" c="dimmed">
288
- {pending ? 'Reading specs…' : '↑↓ move'}
326
+ {unindexed ? `Searching what is read so far: ${unindexed}` : failed > 0 ? `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched` : '↑↓ move'}
289
327
  </Text>
290
328
  <Text size="xs" c="dimmed">
291
329
  ↵ open · esc close
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
- import { fileName, folderOf, kindOfFile, outlinePath, specFile, specKind } from './spec-kind';
3
+ import { fileName, folderOf, kindOfFile, outlinePath, resolveSpec, similarSpecs, specFile, specKind } from './spec-kind';
4
4
 
5
5
  describe('spec kinds', () => {
6
6
  it('gives each notation its own tab and leaves statecharts and notes to the map', () => {
@@ -24,4 +24,17 @@ describe('spec kinds', () => {
24
24
  expect(folderOf('auth/sign-in.feature')).toBe('auth');
25
25
  expect(folderOf('atlas.yaml')).toBe('');
26
26
  });
27
+
28
+ it('reads a link to a spec by any form of its path and finds the listed spec it means', () => {
29
+ expect(specFile('./auth/sign-in.feature')).toBe('auth/sign-in.feature');
30
+ expect(specFile('/specs/auth\\sign-in.feature')).toBe('auth/sign-in.feature');
31
+ const listed = ['auth/sign-in.feature', 'billing/Pay.feature', 'billing/pay-later.feature', 'a/x.feature', 'b/x.feature'];
32
+ expect(resolveSpec('auth/sign-in.feature', listed)).toBe('auth/sign-in.feature');
33
+ expect(resolveSpec('Auth/Sign-In.feature', listed)).toBe('auth/sign-in.feature');
34
+ expect(resolveSpec('sign-in.feature', listed)).toBe('auth/sign-in.feature');
35
+ // Two candidates, or none, leave the link as written.
36
+ expect(resolveSpec('x.feature', listed)).toBe('x.feature');
37
+ expect(resolveSpec('nothing.feature', listed)).toBe('nothing.feature');
38
+ expect(similarSpecs('other/pay.feature', listed)).toEqual(['billing/Pay.feature']);
39
+ });
27
40
  });
@@ -42,9 +42,35 @@ export function outlinePath(file: string) {
42
42
  return normal.startsWith('specs/') ? normal : `specs/${normal}`;
43
43
  }
44
44
 
45
- /** A file path relative to the specs folder, from either form. */
45
+ /**
46
+ * A file path relative to the specs folder, from any form a link may name it by: with or without the `specs/` folder,
47
+ * with a leading `./` or `/`, with backslashes. Nothing here can leave the folder; `..` parts are kept for the reader to refuse.
48
+ */
46
49
  export function specFile(path: string) {
47
- return path.replaceAll('\\', '/').replace(/^specs\//, '');
50
+ return path
51
+ .replaceAll('\\', '/')
52
+ .replace(/^(\.?\/)+/, '')
53
+ .replace(/^specs\//, '')
54
+ .replace(/\/{2,}/g, '/');
55
+ }
56
+
57
+ /**
58
+ * The listed spec a link means. An exact entry wins; otherwise the one listed spec that differs only in letter case, or that
59
+ * the link names by a trailing part of its path. More than one candidate, or none, leaves the link as written.
60
+ */
61
+ export function resolveSpec(file: string, listed: readonly string[]): string {
62
+ if (listed.includes(file)) return file;
63
+ const lower = file.toLowerCase();
64
+ const sameCase = listed.filter((candidate) => candidate.toLowerCase() === lower);
65
+ if (sameCase.length === 1) return sameCase[0]!;
66
+ const tail = listed.filter((candidate) => candidate.toLowerCase().endsWith(`/${lower}`));
67
+ return tail.length === 1 ? tail[0]! : file;
68
+ }
69
+
70
+ /** The listed specs with the same file name as `file`, to offer when a link names one that is not there. */
71
+ export function similarSpecs(file: string, listed: readonly string[], limit = 5): string[] {
72
+ const name = fileName(file).toLowerCase();
73
+ return listed.filter((candidate) => fileName(candidate).toLowerCase() === name).slice(0, limit);
48
74
  }
49
75
 
50
76
  export function fileName(path: string) {