@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.
- package/CHANGELOG.md +12 -0
- package/README.md +52 -8
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +1 -1
- package/dist/reports/src/garbage.d.ts +39 -0
- package/dist/reports/src/garbage.js +82 -0
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -0
- package/dist/schema/src/index.d.ts +1 -0
- package/dist/schema/src/index.js +1 -0
- package/dist/schema/src/server-config.d.ts +7 -49
- package/dist/schema/src/server-config.js +8 -21
- package/dist/schema/src/user-state.d.ts +115 -0
- package/dist/schema/src/user-state.js +54 -0
- package/dist/server/src/app.d.ts +16 -2
- package/dist/server/src/app.js +62 -0
- package/dist/server/src/config.d.ts +16 -21
- package/dist/server/src/config.js +48 -57
- package/dist/server/src/dev.d.ts +1 -1
- package/dist/server/src/dev.js +20 -7
- package/dist/server/src/index.d.ts +5 -1
- package/dist/server/src/index.js +5 -1
- package/dist/server/src/main.d.ts +23 -7
- package/dist/server/src/main.js +72 -26
- package/dist/server/src/openapi.js +24 -5
- package/dist/server/src/projects.js +89 -14
- package/dist/server/src/settings.d.ts +23 -7
- package/dist/server/src/settings.js +50 -60
- package/dist/server/src/storage.d.ts +68 -0
- package/dist/server/src/storage.js +125 -0
- package/dist/server/src/user-state.d.ts +31 -0
- package/dist/server/src/user-state.js +74 -0
- package/docs/agents.md +1 -1
- package/docs/deploying.md +38 -39
- package/docs/reference/cli.md +12 -7
- package/docs/reference/http-api.md +30 -2
- package/docs/reference/reports.md +71 -0
- package/docs/reference/schema.md +102 -16
- package/docs/reference/server.md +263 -41
- package/docs/report-backends.md +0 -144
- package/docs/report-contract.md +1 -1
- package/docs/runners.md +1 -1
- package/docs/server.md +141 -36
- package/docs/statecharts.md +1 -1
- package/docs/ui.md +8 -1
- package/docs/workspaces.md +5 -2
- package/llms-full.txt +238 -234
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/schema/schemas/gc-policy.schema.json +36 -0
- package/packages/schema/schemas/gc-run-result.schema.json +106 -0
- package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
- package/packages/schema/schemas/server-config.schema.json +0 -41
- package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
- package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
- package/packages/schema/schemas/user-state.schema.json +140 -0
- package/packages/server/README.md +11 -6
- package/packages/server/openapi.json +510 -126
- package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
- package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
- package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
- package/packages/visualizer/src/evidence/load-state.tsx +65 -0
- package/packages/visualizer/src/evidence/overview.tsx +4 -6
- package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
- package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
- package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
- package/packages/visualizer/src/find/Finder.tsx +45 -7
- package/packages/visualizer/src/spec-kind.test.ts +14 -1
- 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
|
-
|
|
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
|
|
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
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
|
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) {
|