tuffgal 0.1.0-alpha.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/bin/tuffgal.mjs +2 -0
  4. package/package.json +70 -0
  5. package/src/.gitkeep +0 -0
  6. package/src/cli.ts +158 -0
  7. package/src/commands/init.ts +140 -0
  8. package/src/commands/supervise.ts +267 -0
  9. package/src/config.ts +222 -0
  10. package/src/coverage/flows.ts +90 -0
  11. package/src/coverage/screens.ts +52 -0
  12. package/src/index.ts +28 -0
  13. package/src/reporter/assets/report.css +510 -0
  14. package/src/reporter/assets/report.js +45 -0
  15. package/src/reporter/template.ts +355 -0
  16. package/src/reporter/writeReport.ts +37 -0
  17. package/src/runner/approve.ts +65 -0
  18. package/src/runner/bridges/database.ts +34 -0
  19. package/src/runner/bridges/devServers.ts +174 -0
  20. package/src/runner/coverage.ts +76 -0
  21. package/src/runner/interpolate.ts +36 -0
  22. package/src/runner/resolveLocator.ts +47 -0
  23. package/src/runner/run.ts +177 -0
  24. package/src/runner/runAction.ts +422 -0
  25. package/src/runner/runStory.ts +195 -0
  26. package/src/runner/scheduler.ts +223 -0
  27. package/src/runner/steps/click.ts +16 -0
  28. package/src/runner/steps/input.ts +17 -0
  29. package/src/runner/steps/intercept.ts +28 -0
  30. package/src/runner/steps/navigate.ts +14 -0
  31. package/src/runner/steps/read.ts +20 -0
  32. package/src/runner/steps/scroll.ts +12 -0
  33. package/src/runner/steps/type.ts +11 -0
  34. package/src/runner/steps/wait.ts +5 -0
  35. package/src/runner/steps/waitFor.ts +16 -0
  36. package/src/schema/action.ts +176 -0
  37. package/src/schema/load.ts +94 -0
  38. package/src/schema/result.ts +83 -0
  39. package/src/schema/story.ts +58 -0
  40. package/src/screenshots/baselineStore.ts +114 -0
  41. package/src/screenshots/capture.ts +19 -0
  42. package/src/screenshots/diff.ts +101 -0
@@ -0,0 +1,223 @@
1
+ import { normalisedNeeds } from '../schema/story.ts';
2
+ import type { StoryFile } from '../schema/load.ts';
3
+ import type { StoryResult, StoryStatus } from '../schema/result.ts';
4
+
5
+ export interface ScheduledStory extends StoryFile {
6
+ needs: string[];
7
+ produces: string[];
8
+ }
9
+
10
+ export interface ScheduleSummary {
11
+ ready: ScheduledStory[];
12
+ blocked: ScheduledStory[];
13
+ }
14
+
15
+ /**
16
+ * Validates that every produced label is emitted by at most one story and
17
+ * that every `needs` label has a matching producer. Throws a descriptive
18
+ * error on any mismatch so a typo in a JSON file fails loudly at load time,
19
+ * not at run time.
20
+ */
21
+ export function buildSchedule(stories: StoryFile[]): ScheduledStory[] {
22
+ const scheduled: ScheduledStory[] = stories.map((entry) => ({
23
+ ...entry,
24
+ needs: normalisedNeeds(entry.story),
25
+ produces: entry.story.produces ?? [],
26
+ }));
27
+
28
+ const producerByLabel = new Map<string, string>();
29
+ for (const item of scheduled) {
30
+ for (const label of item.produces) {
31
+ const previous = producerByLabel.get(label);
32
+ if (previous !== undefined) {
33
+ throw new SchedulerError(
34
+ `Label "${label}" is produced by both ${previous} and ${item.file}. Labels must be unique.`,
35
+ );
36
+ }
37
+ producerByLabel.set(label, item.file);
38
+ }
39
+ }
40
+
41
+ for (const item of scheduled) {
42
+ for (const label of item.needs) {
43
+ if (!producerByLabel.has(label)) {
44
+ throw new SchedulerError(
45
+ `${item.file} needs label "${label}" but no story produces it.`,
46
+ );
47
+ }
48
+ }
49
+ }
50
+
51
+ detectCycles(scheduled, producerByLabel);
52
+ return scheduled;
53
+ }
54
+
55
+ interface RunContext {
56
+ satisfied: Set<string>;
57
+ completed: Set<string>;
58
+ inFlight: Set<string>;
59
+ results: Map<string, StoryResult>;
60
+ skipped: Set<string>;
61
+ }
62
+
63
+ export type StoryRunner = (scheduled: ScheduledStory) => Promise<StoryResult>;
64
+
65
+ /**
66
+ * Drains the dependency graph with up to `workerCount` concurrent runs.
67
+ * Stories whose `needs` are satisfied move into the ready pool; the
68
+ * scheduler keeps the pool full until everything has either completed or
69
+ * been transitively blocked by a failure. Stories blocked by failure receive
70
+ * a synthetic `failed` result with a descriptive message so the report
71
+ * shows the chain of effects, not a silent absence.
72
+ */
73
+ export async function drainSchedule(
74
+ scheduled: ScheduledStory[],
75
+ workerCount: number,
76
+ runner: StoryRunner,
77
+ onStart: (item: ScheduledStory) => void,
78
+ onFinish: (item: ScheduledStory, result: StoryResult) => void,
79
+ ): Promise<StoryResult[]> {
80
+ const context: RunContext = {
81
+ satisfied: new Set(),
82
+ completed: new Set(),
83
+ inFlight: new Set(),
84
+ results: new Map(),
85
+ skipped: new Set(),
86
+ };
87
+ const ordered: ScheduledStory[] = [];
88
+
89
+ const allDone = (): boolean =>
90
+ scheduled.every((item) => context.results.has(item.file));
91
+
92
+ await new Promise<void>((resolveOuter, rejectOuter) => {
93
+ const fillSlots = (): void => {
94
+ while (context.inFlight.size < workerCount) {
95
+ const next = pickNextReady(scheduled, context);
96
+ if (!next) {
97
+ break;
98
+ }
99
+ context.inFlight.add(next.file);
100
+ onStart(next);
101
+ runner(next)
102
+ .then((result) => {
103
+ context.inFlight.delete(next.file);
104
+ context.results.set(next.file, result);
105
+ ordered.push(next);
106
+ if (result.status === 'failed') {
107
+ skipDependents(next, scheduled, context);
108
+ } else {
109
+ for (const label of next.produces) {
110
+ context.satisfied.add(label);
111
+ }
112
+ }
113
+ context.completed.add(next.file);
114
+ onFinish(next, result);
115
+ if (allDone()) {
116
+ resolveOuter();
117
+ return;
118
+ }
119
+ fillSlots();
120
+ })
121
+ .catch(rejectOuter);
122
+ }
123
+ if (context.inFlight.size === 0 && allDone()) {
124
+ resolveOuter();
125
+ }
126
+ };
127
+ fillSlots();
128
+ });
129
+
130
+ return ordered.map((item) => context.results.get(item.file)!);
131
+ }
132
+
133
+ function pickNextReady(
134
+ scheduled: ScheduledStory[],
135
+ context: RunContext,
136
+ ): ScheduledStory | undefined {
137
+ return scheduled.find(
138
+ (item) =>
139
+ !context.completed.has(item.file) &&
140
+ !context.inFlight.has(item.file) &&
141
+ !context.skipped.has(item.file) &&
142
+ item.needs.every((label) => context.satisfied.has(label)),
143
+ );
144
+ }
145
+
146
+ function skipDependents(
147
+ failed: ScheduledStory,
148
+ scheduled: ScheduledStory[],
149
+ context: RunContext,
150
+ ): void {
151
+ const failedLabels = new Set(failed.produces);
152
+ for (const item of scheduled) {
153
+ if (context.results.has(item.file) || context.skipped.has(item.file)) {
154
+ continue;
155
+ }
156
+ if (item.needs.some((label) => failedLabels.has(label))) {
157
+ context.skipped.add(item.file);
158
+ context.results.set(item.file, {
159
+ story: item.story.story,
160
+ file: item.file,
161
+ status: 'failed' as StoryStatus,
162
+ startedAt: new Date().toISOString(),
163
+ finishedAt: new Date().toISOString(),
164
+ durationMs: 0,
165
+ actions: [
166
+ {
167
+ action: '(blocked)',
168
+ status: 'skipped',
169
+ startedAt: new Date().toISOString(),
170
+ finishedAt: new Date().toISOString(),
171
+ durationMs: 0,
172
+ failureMessage: `blocked by failed prerequisite ${failed.file}`,
173
+ },
174
+ ],
175
+ });
176
+ context.completed.add(item.file);
177
+ // Propagate skip transitively — the now-skipped item's "produces"
178
+ // labels stay unsatisfied, so other consumers of the same label
179
+ // will be caught by the next loop iteration.
180
+ skipDependents(item, scheduled, context);
181
+ }
182
+ }
183
+ }
184
+
185
+ function detectCycles(
186
+ scheduled: ScheduledStory[],
187
+ producerByLabel: Map<string, string>,
188
+ ): void {
189
+ const visited = new Set<string>();
190
+ const onStack = new Set<string>();
191
+ const visit = (file: string): void => {
192
+ if (onStack.has(file)) {
193
+ throw new SchedulerError(
194
+ `Cycle detected in story dependencies involving ${file}`,
195
+ );
196
+ }
197
+ if (visited.has(file)) {
198
+ return;
199
+ }
200
+ onStack.add(file);
201
+ const item = scheduled.find((entry) => entry.file === file);
202
+ if (item) {
203
+ for (const label of item.needs) {
204
+ const upstream = producerByLabel.get(label);
205
+ if (upstream && upstream !== file) {
206
+ visit(upstream);
207
+ }
208
+ }
209
+ }
210
+ onStack.delete(file);
211
+ visited.add(file);
212
+ };
213
+ for (const item of scheduled) {
214
+ visit(item.file);
215
+ }
216
+ }
217
+
218
+ export class SchedulerError extends Error {
219
+ constructor(message: string) {
220
+ super(message);
221
+ this.name = 'SchedulerError';
222
+ }
223
+ }
@@ -0,0 +1,16 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Hint } from '../../schema/action.ts';
3
+ import { LocatorNotFoundError, resolveLocator } from '../resolveLocator.ts';
4
+
5
+ export async function runClick(
6
+ page: Page,
7
+ hint: Hint,
8
+ timeoutMs: number,
9
+ ): Promise<void> {
10
+ const locator = resolveLocator(page, hint).first();
11
+ try {
12
+ await locator.click({ timeout: timeoutMs });
13
+ } catch (error) {
14
+ throw new LocatorNotFoundError(hint, error);
15
+ }
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Hint } from '../../schema/action.ts';
3
+ import { LocatorNotFoundError, resolveLocator } from '../resolveLocator.ts';
4
+
5
+ export async function runInput(
6
+ page: Page,
7
+ hint: Hint,
8
+ value: string,
9
+ timeoutMs: number,
10
+ ): Promise<void> {
11
+ const locator = resolveLocator(page, hint).first();
12
+ try {
13
+ await locator.fill(value, { timeout: timeoutMs });
14
+ } catch (error) {
15
+ throw new LocatorNotFoundError(hint, error);
16
+ }
17
+ }
@@ -0,0 +1,28 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ /**
4
+ * Installs a network intercept on the page. `pattern` is a Playwright URL
5
+ * glob (e.g. `**\/api/links`). An optional `method` filter scopes the route
6
+ * so a story can simulate a single failing endpoint (POST /api/links) without
7
+ * breaking sibling requests on the same path (GET /api/links). The intercept
8
+ * stays active for the remainder of the story.
9
+ */
10
+ export async function runIntercept(
11
+ page: Page,
12
+ pattern: string,
13
+ respond: { status: number; body?: unknown },
14
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
15
+ ): Promise<void> {
16
+ await page.route(pattern, async (route) => {
17
+ if (method && route.request().method() !== method) {
18
+ await route.fallback();
19
+ return;
20
+ }
21
+ const body = respond.body === undefined ? '' : JSON.stringify(respond.body);
22
+ await route.fulfill({
23
+ status: respond.status,
24
+ contentType: 'application/json',
25
+ body,
26
+ });
27
+ });
28
+ }
@@ -0,0 +1,14 @@
1
+ import type { Page } from 'playwright';
2
+ import type { ResolvedConfig } from '../../config.ts';
3
+
4
+ export async function runNavigate(
5
+ page: Page,
6
+ path: string,
7
+ config: ResolvedConfig,
8
+ ): Promise<void> {
9
+ const url = new URL(path, config.baseUrl).toString();
10
+ await page.goto(url, {
11
+ timeout: config.navigationTimeoutMs,
12
+ waitUntil: 'networkidle',
13
+ });
14
+ }
@@ -0,0 +1,20 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Hint } from '../../schema/action.ts';
3
+ import { LocatorNotFoundError, resolveLocator } from '../resolveLocator.ts';
4
+
5
+ /**
6
+ * Synchronous existence check. Resolves the hint to a Playwright locator
7
+ * and asserts that exactly one matching element is currently attached. Use
8
+ * after a click or input that should leave a known element on screen.
9
+ */
10
+ export async function runRead(page: Page, hint: Hint): Promise<void> {
11
+ const locator = resolveLocator(page, hint).first();
12
+ try {
13
+ const count = await locator.count();
14
+ if (count === 0) {
15
+ throw new Error('no matching element');
16
+ }
17
+ } catch (error) {
18
+ throw new LocatorNotFoundError(hint, error);
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ const DEFAULT_AMOUNT = 600;
4
+
5
+ export async function runScroll(
6
+ page: Page,
7
+ direction: 'up' | 'down',
8
+ amount: number | undefined,
9
+ ): Promise<void> {
10
+ const delta = (amount ?? DEFAULT_AMOUNT) * (direction === 'up' ? -1 : 1);
11
+ await page.mouse.wheel(0, delta);
12
+ }
@@ -0,0 +1,11 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ /**
4
+ * Page-level keyboard input. Delegates straight to Playwright's
5
+ * `keyboard.press`, which understands single keys, named keys, and
6
+ * combinations alike. Examples: "A", "Escape", "Shift+A",
7
+ * "Control+Enter".
8
+ */
9
+ export async function runType(page: Page, value: string): Promise<void> {
10
+ await page.keyboard.press(value);
11
+ }
@@ -0,0 +1,5 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ export async function runWait(page: Page, ms: number): Promise<void> {
4
+ await page.waitForTimeout(ms);
5
+ }
@@ -0,0 +1,16 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Hint } from '../../schema/action.ts';
3
+ import { LocatorNotFoundError, resolveLocator } from '../resolveLocator.ts';
4
+
5
+ export async function runWaitFor(
6
+ page: Page,
7
+ hint: Hint,
8
+ timeoutMs: number,
9
+ ): Promise<void> {
10
+ const locator = resolveLocator(page, hint).first();
11
+ try {
12
+ await locator.waitFor({ state: 'visible', timeout: timeoutMs });
13
+ } catch (error) {
14
+ throw new LocatorNotFoundError(hint, error);
15
+ }
16
+ }
@@ -0,0 +1,176 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Locator hint. The runner uses these to resolve a Playwright `Locator`. The
5
+ * MVP resolver tries role + name, then accessible text, then an explicit
6
+ * selector. `position` is reserved for an AI fallback that picks the right
7
+ * candidate when more than one element matches.
8
+ */
9
+ export const hintSchema = z.object({
10
+ text: z.string().min(1).optional(),
11
+ role: z
12
+ .enum([
13
+ 'alert',
14
+ 'banner',
15
+ 'button',
16
+ 'checkbox',
17
+ 'combobox',
18
+ 'dialog',
19
+ 'form',
20
+ 'heading',
21
+ 'link',
22
+ 'list',
23
+ 'listitem',
24
+ 'main',
25
+ 'menu',
26
+ 'menuitem',
27
+ 'navigation',
28
+ 'option',
29
+ 'progressbar',
30
+ 'radio',
31
+ 'region',
32
+ 'row',
33
+ 'searchbox',
34
+ 'status',
35
+ 'switch',
36
+ 'tab',
37
+ 'table',
38
+ 'textbox',
39
+ ])
40
+ .optional(),
41
+ selector: z.string().min(1).optional(),
42
+ position: z.enum(['header', 'main', 'footer', 'modal']).optional(),
43
+ });
44
+
45
+ export type Hint = z.infer<typeof hintSchema>;
46
+
47
+ export const stepSchema = z.discriminatedUnion('kind', [
48
+ z.object({
49
+ kind: z.literal('navigate'),
50
+ path: z.string().startsWith('/'),
51
+ }),
52
+ z.object({
53
+ kind: z.literal('click'),
54
+ hint: hintSchema,
55
+ }),
56
+ z.object({
57
+ kind: z.literal('input'),
58
+ hint: hintSchema,
59
+ value: z.string(),
60
+ }),
61
+ z.object({
62
+ kind: z.literal('scroll'),
63
+ direction: z.enum(['up', 'down']),
64
+ amount: z.number().int().positive().optional(),
65
+ }),
66
+ z.object({
67
+ kind: z.literal('intercept'),
68
+ pattern: z.string().min(1),
69
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
70
+ respond: z.object({
71
+ status: z.number().int().min(100).max(599),
72
+ body: z.unknown().optional(),
73
+ }),
74
+ }),
75
+ z.object({
76
+ kind: z.literal('waitFor'),
77
+ hint: hintSchema,
78
+ }),
79
+ /**
80
+ * Instant assertion that a hint resolves to an attached element. Unlike
81
+ * `waitFor`, does not poll — the element must already be present when
82
+ * the step runs. Use as a mid-flow checkpoint after a click/input that
83
+ * synchronously updates the DOM.
84
+ */
85
+ z.object({
86
+ kind: z.literal('read'),
87
+ hint: hintSchema,
88
+ }),
89
+ /**
90
+ * Keyboard input directed at the page (not an input field). Passed
91
+ * straight to Playwright's `keyboard.press`, so single keys ("A"),
92
+ * named keys ("Escape", "Tab"), and combinations ("Shift+A",
93
+ * "Control+Enter") all work. Use for hotkeys, modal dismissal, focus
94
+ * cycling.
95
+ */
96
+ z.object({
97
+ kind: z.literal('type'),
98
+ value: z.string().min(1),
99
+ }),
100
+ /**
101
+ * Pauses the action for the given number of milliseconds. Use to absorb
102
+ * staggered enter animations or React-lazy chunk loads that happen after
103
+ * `expect.anyOf` resolves but before paint settles. Staggered enter
104
+ * animations and lazy-loaded chunks are the recurring offenders.
105
+ * Bounded at 5 seconds to discourage hiding genuine flakes.
106
+ */
107
+ z.object({
108
+ kind: z.literal('wait'),
109
+ ms: z.number().int().min(0).max(5000),
110
+ }),
111
+ ]);
112
+
113
+ export type Step = z.infer<typeof stepSchema>;
114
+
115
+ export const actionSchema = z.object({
116
+ action: z
117
+ .string()
118
+ .min(1)
119
+ .regex(/^[a-z0-9-]+$/, 'action names must be lowercase-kebab'),
120
+ parameters: z.array(z.string().min(1)).optional(),
121
+ steps: z.array(stepSchema).min(1),
122
+ screenshot: z.boolean().default(true),
123
+ /**
124
+ * Hints whose matching elements are blacked out before the screenshot is
125
+ * captured. Use sparingly to neutralise non-deterministic content (relative
126
+ * timestamps, randomised suggestions, animated counters) so the diff layer
127
+ * only flags meaningful changes.
128
+ */
129
+ mask: z.array(hintSchema).optional(),
130
+ /**
131
+ * Success criteria the harness polls before capturing the screenshot. The
132
+ * action is only "done" once at least one of the listed hints is visible.
133
+ * Eliminates the entire class of "screenshot snapped mid-render" flakes.
134
+ */
135
+ expect: z
136
+ .object({
137
+ anyOf: z.array(hintSchema).min(1),
138
+ timeoutMs: z.number().int().positive().optional(),
139
+ })
140
+ .optional(),
141
+ /**
142
+ * Bounded retry budget for individual steps. Wraps each step's dispatch so
143
+ * a transient LocatorNotFoundError (UI not yet hydrated) does not fail the
144
+ * action immediately. Steps that succeed on the first try cost no retry.
145
+ */
146
+ retry: z
147
+ .object({
148
+ attempts: z.number().int().min(1).max(5).default(2),
149
+ backoffMs: z.number().int().min(0).default(200),
150
+ })
151
+ .optional(),
152
+ diff: z
153
+ .object({
154
+ /**
155
+ * Pixelmatch per-pixel similarity. Tighter values flag more pixels
156
+ * as changed; loosens anti-aliasing tolerance as it grows. Only
157
+ * controls how the diff PNG is computed — it does not gate the
158
+ * action's pass/changed status.
159
+ */
160
+ pixelThreshold: z.number().min(0).max(1).default(0.1),
161
+ /**
162
+ * Mean SSIM score threshold. Action passes when the score is at
163
+ * least this high. 1.0 = identical; 0.99 = the new default and
164
+ * roughly corresponds to "no perceptible change."
165
+ */
166
+ ssimThreshold: z.number().min(0).max(1).default(0.99),
167
+ /**
168
+ * Legacy pixelmatch-ratio gate. Retained for backward compat with
169
+ * actions that pre-date SSIM. If set, both gates must pass.
170
+ */
171
+ maxDiffRatio: z.number().min(0).max(1).optional(),
172
+ })
173
+ .optional(),
174
+ });
175
+
176
+ export type Action = z.infer<typeof actionSchema>;
@@ -0,0 +1,94 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { join } from 'node:path';
4
+ import { actionSchema, type Action } from './action.ts';
5
+ import { storySchema, type Story } from './story.ts';
6
+
7
+ /**
8
+ * Loads every action JSON file under the configured actions directory and
9
+ * returns a name -> Action map. Throws a typed `LoadError` on the first
10
+ * parse failure so the CLI can print the file path next to the validation
11
+ * error.
12
+ */
13
+ export async function loadActions(
14
+ actionsDir: string,
15
+ ): Promise<Map<string, Action>> {
16
+ const files = await readJsonFiles(actionsDir);
17
+ const actions = new Map<string, Action>();
18
+ for (const { path, contents } of files) {
19
+ const parsed = actionSchema.safeParse(contents);
20
+ if (!parsed.success) {
21
+ throw new LoadError(path, parsed.error.message);
22
+ }
23
+ if (actions.has(parsed.data.action)) {
24
+ throw new LoadError(
25
+ path,
26
+ `duplicate action name "${parsed.data.action}"`,
27
+ );
28
+ }
29
+ actions.set(parsed.data.action, parsed.data);
30
+ }
31
+ return actions;
32
+ }
33
+
34
+ export interface StoryFile {
35
+ file: string;
36
+ story: Story;
37
+ }
38
+
39
+ export async function loadStories(storiesDir: string): Promise<StoryFile[]> {
40
+ const files = await readJsonFiles(storiesDir);
41
+ const stories: StoryFile[] = [];
42
+ for (const { path, contents } of files) {
43
+ const parsed = storySchema.safeParse(contents);
44
+ if (!parsed.success) {
45
+ throw new LoadError(path, parsed.error.message);
46
+ }
47
+ stories.push({ file: basename(path), story: parsed.data });
48
+ }
49
+ return stories;
50
+ }
51
+
52
+ interface JsonFile {
53
+ path: string;
54
+ contents: unknown;
55
+ }
56
+
57
+ async function readJsonFiles(directory: string): Promise<JsonFile[]> {
58
+ const entries = await readdir(directory, { withFileTypes: true });
59
+ const subdirectories = entries
60
+ .filter((entry) => entry.isDirectory())
61
+ .map((entry) => entry.name)
62
+ .sort();
63
+ const jsonNames = entries
64
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
65
+ .map((entry) => entry.name)
66
+ .sort();
67
+ const files: JsonFile[] = [];
68
+ for (const name of jsonNames) {
69
+ const path = join(directory, name);
70
+ const raw = await readFile(path, 'utf8');
71
+ try {
72
+ files.push({ path, contents: JSON.parse(raw) });
73
+ } catch (error) {
74
+ const message = error instanceof Error ? error.message : 'invalid JSON';
75
+ throw new LoadError(path, message);
76
+ }
77
+ }
78
+ for (const subdirectory of subdirectories) {
79
+ const nested = await readJsonFiles(join(directory, subdirectory));
80
+ files.push(...nested);
81
+ }
82
+ return files;
83
+ }
84
+
85
+ export class LoadError extends Error {
86
+ readonly path: string;
87
+ readonly reason: string;
88
+ constructor(path: string, reason: string) {
89
+ super(`Failed to load ${path}: ${reason}`);
90
+ this.name = 'LoadError';
91
+ this.path = path;
92
+ this.reason = reason;
93
+ }
94
+ }