@crvouga/atlas 1.18.0 → 1.19.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 +8 -1
  2. package/dist/reports/src/evidence-detail.js +13 -3
  3. package/dist/reports/src/evidence-types.d.ts +4 -0
  4. package/dist/schema/src/api.d.ts +50 -0
  5. package/dist/schema/src/api.js +2 -0
  6. package/docs/reference/reports.md +4 -0
  7. package/docs/rich-evidence.md +1 -0
  8. package/docs/ui.md +40 -1
  9. package/llms-full.txt +41 -1
  10. package/package.json +1 -1
  11. package/packages/schema/schemas/effective-delta.schema.json +18 -0
  12. package/packages/schema/schemas/effective-evidence.schema.json +18 -0
  13. package/packages/schema/schemas/evidence-page.schema.json +9 -0
  14. package/packages/schema/schemas/layer-rows.schema.json +9 -0
  15. package/packages/schema/schemas/report-run.schema.json +9 -0
  16. package/packages/schema/schemas/review-collection.schema.json +9 -0
  17. package/packages/schema/schemas/review-manifest.schema.json +9 -0
  18. package/packages/schema/schemas/test-catalog.schema.json +9 -0
  19. package/packages/schema/schemas/test-history.schema.json +18 -0
  20. package/packages/server/openapi.json +126 -0
  21. package/packages/visualizer/src/app/AppShell.tsx +2 -2
  22. package/packages/visualizer/src/app/router.tsx +25 -1
  23. package/packages/visualizer/src/components/Header.tsx +15 -4
  24. package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
  25. package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
  26. package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
  27. package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
  28. package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
  29. package/packages/visualizer/src/evidence/behavior.ts +32 -1
  30. package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
  31. package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
  32. package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
  33. package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
  34. package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
  35. package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
  36. package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
  37. package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
  38. package/packages/visualizer/src/explorer/RunsView.tsx +4 -4
  39. package/packages/visualizer/src/explorer/collections.tsx +5 -5
  40. package/packages/visualizer/src/explorer/navigate.ts +17 -0
  41. package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
  42. package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
  43. package/packages/visualizer/src/explorer/playlist.tsx +43 -8
  44. package/packages/visualizer/src/explorer/search.ts +7 -1
  45. package/packages/visualizer/src/find/Finder.tsx +1 -62
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @module Project-wide behavior index
3
+ *
4
+ * Every behavior of every Gherkin feature in the project as one list, so a reader can search "what does this product
5
+ * promise?" without knowing which feature file holds the answer. It adds no source of truth: each feature is read
6
+ * through the same {@link featureBehaviors} the feature page uses, so a behavior has the same coverage state, revisions
7
+ * and recording here as there. Features whose text has not arrived, or cannot be parsed, are counted and named rather
8
+ * than silently absent, so an empty answer is never mistaken for "no such behavior".
9
+ */
10
+ import type { EvidenceView } from '@crvouga/atlas/reports/evidence-view';
11
+ import type { LayerEvidence } from '@crvouga/atlas/reports/evidence-types';
12
+ import type { SpecEntry } from '@crvouga/atlas/reports/explorer-types';
13
+
14
+ import type { DocFacets } from '../find/query';
15
+ import type { FindDoc, FindField, FindStatus } from '../find/rank';
16
+ import { outlinePath } from '../spec-kind';
17
+ import { assessBehavior, type Assessment, type ReleaseIdentity } from './applicability';
18
+ import { COVERAGE, featureBehaviors, type Behavior, type RevisionSource } from './behavior';
19
+ import { featureModel, scenarioLabel, type FeatureModel } from './gherkin';
20
+
21
+ /** One behavior of the project, with where it is written. */
22
+ export type IndexedBehavior = {
23
+ /** Stable for the behavior within the project: the feature file and the behavior's key in it. Safe in an address. */
24
+ readonly id: string;
25
+ /** The feature file relative to the specs folder. */
26
+ readonly file: string;
27
+ /** The path reports name the feature by (`specs/…`). */
28
+ readonly specPath: string;
29
+ /** The feature's title. */
30
+ readonly feature: string;
31
+ /** The domain the spec metadata declares; empty when it declares none (nothing is invented). */
32
+ readonly domain: string;
33
+ /** The rule the scenario sits under, or null. */
34
+ readonly rule: string | null;
35
+ /** The behavior in words: the scenario, and for an outline's example its example label. */
36
+ readonly title: string;
37
+ readonly behavior: Behavior;
38
+ /** Whether the evidence applies to the promise as written now (no release is named in this index). */
39
+ readonly assessment: Assessment;
40
+ };
41
+
42
+ /** The behaviors of the project and what could not be read into them. */
43
+ export type BehaviorIndex = {
44
+ readonly entries: readonly IndexedBehavior[];
45
+ /** Feature files in the project. */
46
+ readonly features: number;
47
+ /** Feature files whose text has not arrived yet; their behaviors are not here yet. */
48
+ readonly unread: number;
49
+ /** Feature files that could not be read or parsed; their behaviors are not here. */
50
+ readonly unreadable: readonly string[];
51
+ };
52
+
53
+ /** What the index reads: the feature files and their texts, the outline (domains, fingerprints) and the report. */
54
+ export type IndexInput = {
55
+ /** The feature files, relative to the specs folder. */
56
+ readonly files: readonly string[];
57
+ /** The text of each file read so far; an empty text is a file that could not be read. */
58
+ readonly texts: ReadonlyMap<string, string>;
59
+ readonly specs: readonly SpecEntry[];
60
+ readonly view: EvidenceView;
61
+ /** The layers the report holds, for the spec fingerprint each recorded. */
62
+ readonly evidence: readonly Pick<LayerEvidence, 'layer' | 'fingerprints'>[];
63
+ readonly release?: ReleaseIdentity;
64
+ };
65
+
66
+ function parse(text: string): FeatureModel | null {
67
+ try {
68
+ return featureModel(text);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Reads every feature into behaviors. Pure: the same inputs give the same list, in file then page order. */
75
+ export function indexBehaviors(input: IndexInput): BehaviorIndex {
76
+ const fingerprints = new Map(input.evidence.map((layer) => [layer.layer.id, layer.fingerprints] as const));
77
+ const entries: IndexedBehavior[] = [];
78
+ const unreadable: string[] = [];
79
+ let unread = 0;
80
+ for (const file of input.files) {
81
+ const text = input.texts.get(file);
82
+ if (text === undefined) {
83
+ unread += 1;
84
+ continue;
85
+ }
86
+ const feature = text === '' ? null : parse(text);
87
+ if (!feature) {
88
+ unreadable.push(file);
89
+ continue;
90
+ }
91
+ const specPath = outlinePath(file);
92
+ const spec = input.specs.find((candidate) => candidate.path === specPath);
93
+ const revisions: RevisionSource = { current: spec?.fingerprint ?? null, recorded: (id) => fingerprints.get(id)?.[specPath] };
94
+ const found = featureBehaviors(feature, input.view.specs.get(specPath)?.slots ?? [], revisions);
95
+ const ruleOf = new Map(feature.rules.flatMap((rule) => rule.scenarios.map((scenario) => [scenario, rule.name] as const)));
96
+ for (const behavior of found.all) {
97
+ const claimed = found.byScenario.has(behavior.scenario);
98
+ entries.push({
99
+ id: `${file}#${behavior.key}`,
100
+ file,
101
+ specPath,
102
+ feature: feature.name || file.split('/').at(-1)!,
103
+ domain: spec?.domain ?? '',
104
+ rule: ruleOf.get(behavior.scenario) ?? null,
105
+ title: claimed
106
+ ? behavior.example
107
+ ? `${behavior.scenario.name} — ${behavior.example}`
108
+ : behavior.scenario.name
109
+ : scenarioLabel(feature.name, behavior.scenario.name),
110
+ behavior,
111
+ assessment: assessBehavior(behavior, revisions, input.release ?? {})
112
+ });
113
+ }
114
+ }
115
+ return { entries, features: input.files.length, unread, unreadable };
116
+ }
117
+
118
+ /** The check status a behavior's search document filters by, or none when nothing reported on it. */
119
+ function statusOf(behavior: Behavior): FindStatus {
120
+ return behavior.slot?.status ?? 'none';
121
+ }
122
+
123
+ /**
124
+ * A behavior as a searchable document for the shared matcher (`find/`): its wording and steps are what a reader searches
125
+ * by; the feature, rule, domain, tags and coverage state are metadata. Its facets feed the typed filters (`status:`,
126
+ * `domain:`, `spec:`, `tag:`, `env:`, `has:video`).
127
+ */
128
+ export function behaviorDoc(entry: IndexedBehavior): FindDoc {
129
+ const { behavior } = entry;
130
+ const outcomes = behavior.slot?.outcomes ?? [];
131
+ const steps = behavior.scenario.steps.map((step) => `${step.keyword} ${step.text}`).join('\n');
132
+ const fields: FindField[] = [{ name: 'title', text: entry.title }];
133
+ if (steps) fields.push({ name: 'steps', text: steps });
134
+ const description = [entry.feature, entry.rule ?? '', behavior.scenario.description].filter(Boolean).join('\n');
135
+ if (description) fields.push({ name: 'description', text: description });
136
+ const metadata = [entry.domain, ...behavior.scenario.tags, COVERAGE[behavior.state].label].filter(Boolean);
137
+ fields.push({ name: 'metadata', text: metadata.join('\n') });
138
+ fields.push({ name: 'path', text: entry.specPath });
139
+ const facets: DocFacets = {
140
+ status: behavior.slot ? [behavior.slot.status] : [],
141
+ env: [...new Set(outcomes.map((result) => result.layer.environment ?? '(none)'))],
142
+ producer: [...new Set(outcomes.map((result) => result.layer.producer))],
143
+ domain: [entry.domain || '(no domain)'],
144
+ spec: [entry.specPath],
145
+ tag: behavior.scenario.tags.map((tag) => tag.replace(/^@/, '')),
146
+ branch: [...new Set(outcomes.flatMap((result) => (result.layer.branch ? [result.layer.branch] : [])))],
147
+ video: behavior.demo !== null
148
+ };
149
+ return {
150
+ id: entry.id,
151
+ group: 'Specs',
152
+ kind: 'scenario',
153
+ title: entry.title,
154
+ subtitle: entry.feature,
155
+ fields,
156
+ status: statusOf(behavior),
157
+ related: [],
158
+ target: { to: '/specs/$kind', kind: 'gherkin', search: { spec: entry.file, ...(behavior.slot ? { check: behavior.slot.id } : {}) } },
159
+ recording: null,
160
+ facets
161
+ };
162
+ }
@@ -26,6 +26,7 @@ import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './g
26
26
  import { Origin, StatusMark } from './marks';
27
27
  import { MomentPlayback, stepsOfRecording, useMomentVideo, useRequestedMoment } from './moments';
28
28
  import { outcomeHeadline, videosIn } from './outcome';
29
+ import { ScopedSummary } from './scoped-summary-view';
29
30
 
30
31
  /** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
31
32
  export function shortRevision(fingerprint: string): string {
@@ -83,6 +84,18 @@ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
83
84
  return at < 0 ? [] : steps.slice(at);
84
85
  }
85
86
 
87
+ /**
88
+ * The steps a behavior reads as: an outline's example with its values filled in, else the scenario's own. `expected` is
89
+ * what should be true afterwards; `named` is true when the recorded example name matches no row, so nothing was filled in.
90
+ */
91
+ export function behaviorSteps(behavior: Behavior) {
92
+ const { scenario, example } = behavior;
93
+ const at = example === null ? -1 : exampleIndexFor(scenario, example);
94
+ const entry = at < 0 ? null : exampleRows(scenario)[at]!;
95
+ const steps = entry ? fillExample(scenario.steps, entry) : scenario.steps;
96
+ return { steps, entry, expected: expectedSteps(steps), named: example !== null && entry === null };
97
+ }
98
+
86
99
  /** The recording itself: the one player the steps beside it seek, which marks the step playing. */
87
100
  function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }) {
88
101
  const moments = useMomentVideo(attachment);
@@ -101,8 +114,8 @@ function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }
101
114
  );
102
115
  }
103
116
 
104
- /** The recording of one result, read only when this is on screen. */
105
- function Player({ result }: { result: EvidenceResult }) {
117
+ /** The recording of one result, read only when this is on screen, with the steps beside it that seek it. */
118
+ export function Player({ result }: { result: EvidenceResult }) {
106
119
  const detail = useCheckDetail(result.layer, result.row);
107
120
  const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
108
121
  const requested = useRequestedMoment();
@@ -158,6 +171,24 @@ function Player({ result }: { result: EvidenceResult }) {
158
171
  );
159
172
  }
160
173
 
174
+ /** What the result shown recorded and where it came from: its status, environment, run, commit and time. */
175
+ export function Provenance({ subject }: { subject: EvidenceResult }) {
176
+ const { backends } = useEvidence();
177
+ const now = useNow();
178
+ return (
179
+ <Group gap="xs" wrap="wrap" align="center">
180
+ <StatusMark status={subject.row.status} size={14} />
181
+ <Text span inherit fw={600}>
182
+ {statusWord(subject.row.status)}
183
+ </Text>
184
+ <Text span inherit c="dimmed">
185
+ in {environmentLabel(subject.layer.environment)}
186
+ </Text>
187
+ <Origin layer={subject.layer} backends={backends} now={now} />
188
+ </Group>
189
+ );
190
+ }
191
+
161
192
  /** Read, Watch and Inspect for one behavior. `inspect` is the technical record, shown only under Inspect. */
162
193
  export function BehaviorPanel({
163
194
  behavior,
@@ -175,14 +206,8 @@ export function BehaviorPanel({
175
206
  /** The tab to open on; by default Watch when there is something to watch, Inspect while a run is in progress (its progress lives there), else Read. */ start?:
176
207
  'read' | 'watch' | 'inspect';
177
208
  }) {
178
- const { backends } = useEvidence();
179
- const now = useNow();
180
209
  const { scenario, example, demo, subject, revision, state } = behavior;
181
- const at = example === null ? -1 : exampleIndexFor(scenario, example);
182
- const entry = at < 0 ? null : exampleRows(scenario)[at]!;
183
- const steps = useMemo(() => (entry ? fillExample(scenario.steps, entry) : scenario.steps), [scenario, entry]);
184
- const expected = expectedSteps(steps);
185
- const named = example !== null && entry === null;
210
+ const { steps, entry, expected, named } = useMemo(() => behaviorSteps(behavior), [behavior]);
186
211
  // The coverage state is the check's combined status; this says what the recording or result shown here recorded, apart from it.
187
212
  const outcome =
188
213
  subject && behavior.slot ? (
@@ -229,7 +254,6 @@ export function BehaviorPanel({
229
254
  <CoverageBadge state={state} />
230
255
  {behavior.representative && <Badge variant="outline">Representative demo</Badge>}
231
256
  </Group>
232
- {outcome}
233
257
  {expected.length > 0 && (
234
258
  <Box component="section" aria-label="Expected outcome">
235
259
  <Title order={4} mb="xs">
@@ -250,19 +274,9 @@ export function BehaviorPanel({
250
274
  Nothing to watch. {COVERAGE[state].meaning}
251
275
  </Text>
252
276
  )}
253
- {subject && (
254
- <Group gap="xs" wrap="wrap" align="center">
255
- <StatusMark status={subject.row.status} size={14} />
256
- <Text span inherit fw={600}>
257
- {statusWord(subject.row.status)}
258
- </Text>
259
- <Text span inherit c="dimmed">
260
- in {environmentLabel(subject.layer.environment)}
261
- </Text>
262
- <Origin layer={subject.layer} backends={backends} now={now} />
263
- </Group>
264
- )}
277
+ {subject && <Provenance subject={subject} />}
265
278
  <RevisionLine revision={revision} />
279
+ <ScopedSummary behavior={behavior} />
266
280
  {applicability && <ApplicabilitySummary assessment={applicability} release={release} />}
267
281
  </Stack>
268
282
  </Tabs.Panel>
@@ -0,0 +1,41 @@
1
+ import { beforeEach, expect, test } from 'bun:test';
2
+
3
+ import { forgetReturn, recallReturn, rememberReturn, scopeKey } from './behavior-return';
4
+
5
+ const store = new Map<string, string>();
6
+ beforeEach(() => {
7
+ store.clear();
8
+ (globalThis as { sessionStorage?: unknown }).sessionStorage = {
9
+ getItem: (key: string) => store.get(key) ?? null,
10
+ setItem: (key: string, value: string) => void store.set(key, value),
11
+ removeItem: (key: string) => void store.delete(key)
12
+ };
13
+ });
14
+
15
+ test('a remembered point comes back only to the scope it was taken in', () => {
16
+ const scope = scopeKey({ q: 'refund', coverage: 'failed' });
17
+ rememberReturn({ scope, scrollY: 480, shown: 80, focus: 'a.feature#Refund' });
18
+ expect(recallReturn(scope)).toEqual({ scope, scrollY: 480, shown: 80, focus: 'a.feature#Refund' });
19
+ // Another search, or none, never gets the scroll of this one.
20
+ expect(recallReturn(scopeKey({ q: 'pay' }))).toBeNull();
21
+ expect(recallReturn(scopeKey({}))).toBeNull();
22
+ });
23
+
24
+ test('reading a point does not forget it until it is given back', () => {
25
+ const scope = scopeKey({});
26
+ rememberReturn({ scope, scrollY: 10, shown: 40, focus: null });
27
+ expect(recallReturn(scope)).not.toBeNull();
28
+ expect(recallReturn(scope)).not.toBeNull();
29
+ forgetReturn();
30
+ expect(recallReturn(scope)).toBeNull();
31
+ });
32
+
33
+ test('unreadable or unavailable storage only means nothing is restored', () => {
34
+ store.set('atlas-behaviors-return', '{not json');
35
+ expect(recallReturn(scopeKey({}))).toBeNull();
36
+ store.set('atlas-behaviors-return', JSON.stringify({ scope: scopeKey({}), scrollY: 'far' }));
37
+ expect(recallReturn(scopeKey({}))).toBeNull();
38
+ (globalThis as { sessionStorage?: unknown }).sessionStorage = undefined;
39
+ expect(() => rememberReturn({ scope: 's', scrollY: 1, shown: 1, focus: null })).not.toThrow();
40
+ expect(recallReturn('s')).toBeNull();
41
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @module Returning from the read-and-watch workspace
3
+ *
4
+ * Opening a behavior in the dedicated workspace replaces the index, so the index would come back at the top with
5
+ * nothing selected. The scope (the search words and the coverage filter) and the selection are in the address and come
6
+ * back by themselves. What an address cannot hold is where the reader had scrolled to, how much of a long list had been
7
+ * shown, and which row had focus: they are remembered here for the session, against the scope they were taken in, and
8
+ * given back once, only to the same scope.
9
+ */
10
+
11
+ const KEY = 'atlas-behaviors-return';
12
+
13
+ /** What the index remembers when it is left for the workspace. */
14
+ export type ReturnPoint = {
15
+ /** The scope the point was taken in ({@link scopeKey}); it is only used for that scope. */
16
+ readonly scope: string;
17
+ /** How far the page was scrolled, in pixels. */
18
+ readonly scrollY: number;
19
+ /** How many rows of the list had been shown. */
20
+ readonly shown: number;
21
+ /** The behavior whose row had focus, or null. */
22
+ readonly focus: string | null;
23
+ };
24
+
25
+ /** The scope of an index: its search words and its coverage filter. */
26
+ export function scopeKey(search: { readonly q?: string | undefined; readonly coverage?: string | undefined }): string {
27
+ return JSON.stringify([search.q ?? '', search.coverage ?? '']);
28
+ }
29
+
30
+ /** Remembers where the reader was. Storage that is unavailable or full only means nothing is restored. */
31
+ export function rememberReturn(point: ReturnPoint): void {
32
+ try {
33
+ sessionStorage.setItem(KEY, JSON.stringify(point));
34
+ } catch {
35
+ // Not being able to remember means the list opens at its top.
36
+ }
37
+ }
38
+
39
+ /** The remembered point, if it was taken in `scope`; a point of another scope is never used. Reading does not forget it. */
40
+ export function recallReturn(scope: string): ReturnPoint | null {
41
+ try {
42
+ const raw = sessionStorage.getItem(KEY);
43
+ if (!raw) return null;
44
+ const point = JSON.parse(raw) as Partial<ReturnPoint>;
45
+ if (point.scope !== scope || typeof point.scrollY !== 'number' || typeof point.shown !== 'number') return null;
46
+ return { scope, scrollY: point.scrollY, shown: point.shown, focus: typeof point.focus === 'string' ? point.focus : null };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /** Forgets the remembered point, once it has been given back. */
53
+ export function forgetReturn(): void {
54
+ try {
55
+ sessionStorage.removeItem(KEY);
56
+ } catch {
57
+ // Nothing was remembered.
58
+ }
59
+ }
@@ -11,7 +11,7 @@
11
11
  import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
12
12
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
13
13
 
14
- import { exampleIndexFor, exampleRows, scenarioLabel, type FeatureScenario } from './gherkin';
14
+ import { exampleIndexFor, exampleRows, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
15
15
 
16
16
  /** Where a behavior stands, from the most reassuring to the least. */
17
17
  export type CoverageState =
@@ -266,3 +266,34 @@ export function coverageCounts(behaviors: readonly Behavior[]): CoverageCounts {
266
266
  for (const behavior of behaviors) counts[behavior.state] += 1;
267
267
  return counts;
268
268
  }
269
+
270
+ /** Every behavior of a feature, by scenario, with the ones no scenario claims. */
271
+ export type FeatureBehaviors = {
272
+ /** The behaviors of each scenario (a background is not one), in the order the file has them. */
273
+ readonly byScenario: ReadonlyMap<FeatureScenario, readonly Behavior[]>;
274
+ /** The recorded checks no scenario of the file claims: recorded against the file under a name it does not have now. */
275
+ readonly leftOver: readonly CheckSlot[];
276
+ /** Every behavior, claimed or not, in page order; a result no scenario claims is still a behavior that was checked. */
277
+ readonly all: readonly Behavior[];
278
+ };
279
+
280
+ /** A scenario that stands for a recorded check no scenario of the file names. */
281
+ function strayScenario(name: string): FeatureScenario {
282
+ return { keyword: 'Scenario', name, description: '', background: false, tags: [], steps: [], examples: [] };
283
+ }
284
+
285
+ /**
286
+ * Reads a whole feature as behaviors: its scenarios (an outline as one per example), each with the recorded checks filed
287
+ * under it, and the checks no scenario claims. The feature page and the project-wide behavior index both read a feature
288
+ * through this, so a behavior is the same behavior wherever it is listed. `feature` is null when the file could not be parsed.
289
+ */
290
+ export function featureBehaviors(feature: FeatureModel | null, slots: readonly CheckSlot[], revisions: RevisionSource): FeatureBehaviors {
291
+ // A file that cannot be read has no scenarios: every check recorded against it is one nothing claims.
292
+ const filed = feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: [...slots] };
293
+ const byScenario = new Map<FeatureScenario, Behavior[]>();
294
+ for (const scenario of feature?.rules.flatMap((rule) => rule.scenarios.filter((each) => !each.background)) ?? []) {
295
+ byScenario.set(scenario, scenarioBehaviors(scenario, filed.byScenario.get(scenario.name) ?? [], feature!.name, revisions));
296
+ }
297
+ const other = filed.leftOver.map((slot) => behaviorOf(strayScenario(slot.title), slot, null, revisions, slot.key));
298
+ return { byScenario, leftOver: filed.leftOver, all: [...[...byScenario.values()].flat(), ...other] };
299
+ }