@crvouga/atlas 1.0.0 → 1.2.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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/artifact-query.d.ts +123 -0
  3. package/dist/reports/src/artifact-query.js +234 -0
  4. package/dist/reports/src/evidence-detail.js +2 -1
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/test-catalog.d.ts +101 -5
  7. package/dist/reports/src/test-catalog.js +140 -5
  8. package/dist/schema/src/api.d.ts +225 -0
  9. package/dist/schema/src/api.js +41 -2
  10. package/dist/server/src/app.js +16 -5
  11. package/dist/server/src/openapi.js +41 -3
  12. package/docs/reference/http-api.md +84 -2
  13. package/docs/reference/reports.md +371 -7
  14. package/docs/reference/schema.md +32 -0
  15. package/docs/rich-evidence.md +31 -0
  16. package/docs/server.md +4 -1
  17. package/docs/ui.md +8 -1
  18. package/llms-full.txt +127 -4
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/effective-delta.schema.json +14 -0
  21. package/packages/schema/schemas/effective-evidence.schema.json +14 -0
  22. package/packages/schema/schemas/evidence-page.schema.json +7 -0
  23. package/packages/schema/schemas/layer-rows.schema.json +7 -0
  24. package/packages/schema/schemas/report-run.schema.json +35 -0
  25. package/packages/schema/schemas/report-runs.schema.json +221 -0
  26. package/packages/schema/schemas/test-catalog.schema.json +7 -0
  27. package/packages/schema/schemas/test-history.schema.json +14 -0
  28. package/packages/server/openapi.json +2473 -1035
  29. package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
  30. package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
  31. package/packages/visualizer/src/evidence/load-state.tsx +65 -0
  32. package/packages/visualizer/src/evidence/overview.tsx +4 -6
  33. package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
  34. package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
  35. package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
  36. package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
  37. package/packages/visualizer/src/explorer/Explorer.tsx +28 -3
  38. package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
  39. package/packages/visualizer/src/explorer/catalog.tsx +97 -12
  40. package/packages/visualizer/src/explorer/filters.tsx +279 -0
  41. package/packages/visualizer/src/explorer/search.ts +81 -6
  42. package/packages/visualizer/src/explorer/views.ts +75 -0
  43. package/packages/visualizer/src/find/Finder.tsx +45 -7
  44. package/packages/visualizer/src/spec-kind.test.ts +14 -1
  45. package/packages/visualizer/src/spec-kind.ts +28 -2
@@ -3,8 +3,13 @@
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
5
  * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, and the exact layer a test's detail is pinned to. Filters that
6
- * take several values are comma-separated (`status=failed,flaky`). Anything unreadable is dropped, never an error.
6
+ * take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
7
+ * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
+ *
9
+ * The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
10
+ * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
7
11
  */
12
+ import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
8
13
  import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
9
14
  import { z } from 'zod/v4';
10
15
 
@@ -30,11 +35,27 @@ export const ExplorerSearchFields = {
30
35
  domain: text,
31
36
  spec: text,
32
37
  tag: text,
38
+ /** Artifact tags (`demo`, shown `#demo`): tests with an attachment carrying one; runs and artifacts likewise. */
39
+ artifactTag: text,
40
+ /** With `artifactTag`: also the tags attachments inherit from their check and run. */
41
+ inherited: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
42
+ /** Media types of attachments (`video`, `image`…): runs and artifacts. */
43
+ type: text,
44
+ /** `key=value` labels: runs and artifacts. */
45
+ label: text,
46
+ /** Branches: runs and artifacts. */
47
+ branch: text,
48
+ /** Outcomes of a run or recorded result (`passed`, `failed`…). */
49
+ outcome: text,
50
+ /** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
51
+ since: text,
52
+ /** Only records until the end of this day (`YYYY-MM-DD`, local time). */
53
+ until: text,
33
54
  /** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
34
55
  inRun: text,
35
56
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
36
57
  sort: z.enum(TEST_SORTS).optional().catch(undefined),
37
- tab: z.enum(['runs']).optional().catch(undefined),
58
+ tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
38
59
  /** The run opened in the Runs tab. */
39
60
  openRun: text,
40
61
  /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
@@ -53,10 +74,18 @@ export type ExplorerSearch = {
53
74
  readonly domain?: string;
54
75
  readonly spec?: string;
55
76
  readonly tag?: string;
77
+ readonly artifactTag?: string;
78
+ readonly inherited?: true;
79
+ readonly type?: string;
80
+ readonly label?: string;
81
+ readonly branch?: string;
82
+ readonly outcome?: string;
83
+ readonly since?: string;
84
+ readonly until?: string;
56
85
  readonly inRun?: string;
57
86
  readonly group?: TestGroup;
58
87
  readonly sort?: TestSort;
59
- readonly tab?: 'runs';
88
+ readonly tab?: 'runs' | 'artifacts';
60
89
  readonly openRun?: string;
61
90
  readonly layer?: string;
62
91
  readonly video?: true;
@@ -64,12 +93,12 @@ export type ExplorerSearch = {
64
93
 
65
94
  /** The values of a comma-separated filter, empty when it is not set. */
66
95
  export function values(value: string | undefined): string[] {
67
- return value ? value.split(',').map((item) => item.trim()).filter(Boolean) : [];
96
+ return value ? splitList(value) : [];
68
97
  }
69
98
 
70
99
  /** A list of values as one comma-separated parameter, or undefined (left out of the address) when empty. */
71
100
  export function joined(list: readonly string[]): string | undefined {
72
- return list.length ? list.join(',') : undefined;
101
+ return joinList(list);
73
102
  }
74
103
 
75
104
  /** Adds a value to a comma-separated filter, or removes it when it is there. */
@@ -88,6 +117,8 @@ export function queryOf(search: ExplorerSearch): TestQuery {
88
117
  domain: values(search.domain),
89
118
  spec: values(search.spec),
90
119
  tag: values(search.tag),
120
+ artifactTag: values(search.artifactTag),
121
+ ...(search.inherited ? { inherited: true } : {}),
91
122
  sort: search.sort ?? 'spec'
92
123
  };
93
124
  }
@@ -99,5 +130,49 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
99
130
 
100
131
  /** True when any filter or search narrows the list. */
101
132
  export function narrowed(search: ExplorerSearch): boolean {
102
- return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.inRun);
133
+ return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.artifactTag || search.inRun);
134
+ }
135
+
136
+ /** The filters of the Runs and Artifacts tabs, in the address. */
137
+ export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
138
+
139
+ /** A patch that removes every filter of the Runs and Artifacts tabs. */
140
+ export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
141
+
142
+ /** True when an address narrows the Runs and Artifacts tabs. */
143
+ export function filtered(search: ExplorerSearch): boolean {
144
+ return FILTER_FIELDS.some((field) => field !== 'inherited' && search[field] !== undefined);
145
+ }
146
+
147
+ /** Midnight at the start (or the last millisecond) of a `YYYY-MM-DD` day in the viewer's local time, or undefined. */
148
+ function dayMs(day: string | undefined, end: boolean): number | undefined {
149
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day ?? '');
150
+ if (!match) return undefined;
151
+ const [year, month, date] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];
152
+ const value = new Date(year, month, date + (end ? 1 : 0)).getTime() - (end ? 1 : 0);
153
+ return Number.isFinite(value) ? value : undefined;
154
+ }
155
+
156
+ /** The query the Runs and Artifacts tabs ask for: the address's filters in the shape the report API and `matchesFilter` read. */
157
+ export function filterOf(search: ExplorerSearch): ArtifactFilter {
158
+ const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
159
+ const since = dayMs(search.since, false);
160
+ const until = dayMs(search.until, true);
161
+ const parts = {
162
+ q: search.q,
163
+ artifactTag: list(search.artifactTag),
164
+ inherited: search.inherited,
165
+ type: list(search.type),
166
+ label: list(search.label),
167
+ environment: list(search.env),
168
+ producer: list(search.producer),
169
+ branch: list(search.branch),
170
+ outcome: list(search.outcome),
171
+ domain: list(search.domain),
172
+ spec: list(search.spec),
173
+ since,
174
+ until,
175
+ video: search.video
176
+ };
177
+ return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ArtifactFilter;
103
178
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @module Saved views
3
+ *
4
+ * A saved view is a name for a set of filters on the Runs or Artifacts tab of one project, kept in this browser. It is a
5
+ * live query, not a snapshot: opening it asks again and shows what matches the newest report, so its results change as
6
+ * runs arrive. (An immutable collection of reviewed results is a different thing, and is not what this stores.) Unreadable
7
+ * storage is treated as no saved views, never as an error.
8
+ */
9
+ import { FILTER_FIELDS, type ExplorerSearch } from './search';
10
+
11
+ /** The storage key of the saved views of this browser, all projects. */
12
+ export const VIEWS_KEY = 'atlas.savedViews.v1';
13
+
14
+ /** A saved live query. `filters` holds only the filter fields of the address, as the address writes them. */
15
+ export type SavedView = {
16
+ readonly id: string;
17
+ readonly name: string;
18
+ /** The tab it opens on. */
19
+ readonly tab: 'runs' | 'artifacts';
20
+ /** The project it belongs to (the page's `?project=`), or an empty string. */
21
+ readonly project: string;
22
+ readonly filters: Readonly<Record<string, string | true>>;
23
+ readonly savedAtMs: number;
24
+ };
25
+
26
+ /** The filters of an address, ready to save: only the fields that narrow, with their values. */
27
+ export function filtersOf(search: ExplorerSearch): Record<string, string | true> {
28
+ const filters: Record<string, string | true> = {};
29
+ for (const field of FILTER_FIELDS) {
30
+ const value = search[field];
31
+ if (value !== undefined) filters[field] = value;
32
+ }
33
+ return filters;
34
+ }
35
+
36
+ /** Whether an address is exactly a saved view's query on its tab (ignoring anything that is not a filter). */
37
+ export function isView(view: SavedView, search: ExplorerSearch): boolean {
38
+ if ((search.tab ?? 'tests') !== view.tab) return false;
39
+ const now = filtersOf(search);
40
+ const keys = Object.keys(now);
41
+ return keys.length === Object.keys(view.filters).length && keys.every((key) => now[key] === view.filters[key]);
42
+ }
43
+
44
+ /** The saved views of this browser, oldest first; empty when none or when storage cannot be read. */
45
+ export function readViews(storage: Pick<Storage, 'getItem'> | undefined = safeStorage()): SavedView[] {
46
+ try {
47
+ const parsed: unknown = JSON.parse(storage?.getItem(VIEWS_KEY) ?? '[]');
48
+ if (!Array.isArray(parsed)) return [];
49
+ return parsed.filter(
50
+ (item): item is SavedView =>
51
+ !!item && typeof item.id === 'string' && typeof item.name === 'string' && (item.tab === 'runs' || item.tab === 'artifacts') && typeof item.project === 'string' && !!item.filters && typeof item.filters === 'object'
52
+ );
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /** Keeps the saved views. Returns false when storage refused them (full, private or unavailable), so the page can say so. */
59
+ export function writeViews(views: readonly SavedView[], storage: Pick<Storage, 'setItem'> | undefined = safeStorage()): boolean {
60
+ try {
61
+ if (!storage) return false;
62
+ storage.setItem(VIEWS_KEY, JSON.stringify(views));
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function safeStorage(): Storage | undefined {
70
+ try {
71
+ return typeof localStorage === 'undefined' ? undefined : localStorage;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
@@ -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) {