@drkmttr/adhere 0.15.0 → 0.17.5

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.
@@ -0,0 +1,437 @@
1
+ import { isNote, withoutComments } from "#comments.ts";
2
+ import type { JudgedFile, ReadState } from "#config.ts";
3
+ import { WalkUnavailable } from "#models/Audit.ts";
4
+ import { suppressionsOf } from "#suppress.ts";
5
+ import { Context, Effect, Layer, Path, Schema, Scope, Semaphore } from "effect";
6
+ import { access, readFile } from "node:fs/promises";
7
+ import { createRequire } from "node:module";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import packageJson from "../../package.json";
10
+ import { NativeLsp } from "./NativeLsp.ts";
11
+ import type { Project, Symbol, Type } from "typescript/async";
12
+ import type { Node, SourceFile } from "typescript/ast";
13
+
14
+ export type SourceReferences = Record<string, Record<string, string>>;
15
+ export type SourceReadState = ReadState & {
16
+ readonly symbols?: Record<string, string>;
17
+ readonly references?: SourceReferences;
18
+ };
19
+ export interface SourceReadRequest {
20
+ readonly symbols: boolean;
21
+ readonly references: boolean;
22
+ readonly includeComments: boolean;
23
+ }
24
+ export class SourceReads extends Context.Service<
25
+ SourceReads,
26
+ {
27
+ readonly prepare: (files: readonly string[]) => Effect.Effect<void, WalkUnavailable>;
28
+ readonly read: (
29
+ file: JudgedFile,
30
+ lines: readonly string[],
31
+ requested: SourceReadRequest,
32
+ ) => Effect.Effect<ReadState, WalkUnavailable>;
33
+ }
34
+ >()("@drkmttr/adhere/services/SourceReads") {}
35
+
36
+ type Ast = typeof import("typescript/ast");
37
+ type Sdk = typeof import("typescript/async");
38
+ const CompilerPackage = Schema.fromJsonString(
39
+ Schema.Struct({
40
+ name: Schema.String,
41
+ version: Schema.String,
42
+ os: Schema.Array(Schema.String),
43
+ cpu: Schema.Array(Schema.String),
44
+ }),
45
+ );
46
+ const once = <K, V>(cache: Map<K, Promise<V>>, key: K, load: () => Promise<V>): Promise<V> => {
47
+ const pending = cache.get(key) ?? load();
48
+ cache.set(key, pending);
49
+ return pending;
50
+ };
51
+ const sorted = <V>(entries: Iterable<readonly [string, V]>): Record<string, V> =>
52
+ Object.fromEntries([...entries].sort(([a], [b]) => a.localeCompare(b)));
53
+ // Instantiated members can have different symbol IDs while sharing their source declarations.
54
+ const bindingKeyOf = (symbol: Symbol): string =>
55
+ symbol.declarations.length
56
+ ? symbol.declarations
57
+ .map(({ path, index }) => JSON.stringify([path, index]))
58
+ .sort()
59
+ .join("\n")
60
+ : `symbol:${symbol.id}`;
61
+
62
+ async function readOf(
63
+ native: NativeLsp,
64
+ project: Project,
65
+ root: string,
66
+ ast: Ast,
67
+ sdk: Sdk,
68
+ file: JudgedFile,
69
+ requested: SourceReadRequest,
70
+ ): Promise<SourceReadState> {
71
+ const source = await native.sdk("getSourceFile", () =>
72
+ project.program.getSourceFile(resolve(root, file.path)),
73
+ );
74
+ if (!source)
75
+ throw new Error(`${file.path} is absent from its prepared native TypeScript project.`);
76
+ if (source.text !== file.contents)
77
+ throw new Error(
78
+ `${file.path} changed between the source walk and native TypeScript snapshot. Rerun lint after saving files so the judged code and semantic context match.`,
79
+ );
80
+ // Compare compiler identity paths canonically, but preserve source spelling in display paths.
81
+ const canonicalOf = (path: string) => native.api.getCanonicalFileName(path);
82
+ const canonicalRoot = canonicalOf(root);
83
+ const pathnameOf = (path: string) => relative(root, path).split(sep).join("/");
84
+ const owned = (path: string) => {
85
+ const pathname = relative(canonicalRoot, canonicalOf(path)).split(sep).join("/");
86
+ return (
87
+ !isAbsolute(pathname) &&
88
+ pathname !== ".." &&
89
+ !pathname.startsWith("../") &&
90
+ !pathname.split("/").includes("node_modules") &&
91
+ !/\.d\.[cm]?ts$/i.test(pathname)
92
+ );
93
+ };
94
+ const nodes: Node[] = [];
95
+ const visit = (node: Node): void => {
96
+ if (ast.isJSDoc(node)) return;
97
+ if (
98
+ (ast.isIdentifier(node) || ast.isPrivateIdentifier(node)) &&
99
+ !(ast.isImportSpecifier(node.parent) && node.parent.propertyName === node)
100
+ )
101
+ nodes.push(node);
102
+ node.forEachChild(visit);
103
+ };
104
+ visit(source);
105
+ const symbols = nodes.length
106
+ ? await native.sdk("getSymbolAtLocation", () => project.checker.getSymbolAtLocation(nodes))
107
+ : [];
108
+ const types =
109
+ requested.symbols && nodes.length
110
+ ? await native.sdk("getTypeAtLocation", () => project.checker.getTypeAtLocation(nodes))
111
+ : [];
112
+ const canonical = new Map<number, Promise<Symbol | undefined>>();
113
+ const definitions = new Map<number, Promise<Node[]>>();
114
+ const targetOf = (symbol: Symbol) =>
115
+ once(canonical, symbol.id, async () => {
116
+ const target =
117
+ symbol.flags & sdk.SymbolFlags.Alias
118
+ ? await native.sdk("getAliasedSymbol", () => project.checker.getAliasedSymbol(symbol))
119
+ : symbol;
120
+ return (await native.sdk("isUnknownSymbol", () => project.checker.isUnknownSymbol(target)))
121
+ ? undefined
122
+ : target;
123
+ });
124
+ const definitionsOf = (symbol: Symbol) =>
125
+ once(definitions, symbol.id, async () => {
126
+ const found: Node[] = [];
127
+ for (const handle of symbol.declarations) {
128
+ if (!owned(handle.path)) continue;
129
+ const node = await native.sdk("resolveDeclaration", () => handle.resolve());
130
+ if (!node)
131
+ throw new Error(`Cannot resolve a native TypeScript declaration in ${handle.path}.`);
132
+ found.push(node);
133
+ }
134
+ return found;
135
+ });
136
+ const usedOf = (node: Node) =>
137
+ ast.isShorthandPropertyAssignment(node.parent) ||
138
+ ast.isPropertyAccessExpression(node.parent) ||
139
+ ast.isExportSpecifier(node.parent) ||
140
+ (node.parent as Node & { readonly name?: Node }).name !== node;
141
+ const usedBindings = new Set(
142
+ nodes.flatMap((node, index) =>
143
+ symbols[index] && usedOf(node) ? [`${symbols[index]!.id}:${node.getText(source)}`] : [],
144
+ ),
145
+ );
146
+ const candidates = new Map<
147
+ string,
148
+ { name: string; node: Node; symbol: Symbol; type: Type | undefined }
149
+ >();
150
+ const excerpts = new Map<
151
+ string,
152
+ Map<string, { source: SourceFile; windows: Array<{ first: number; last: number }> }>
153
+ >();
154
+ for (const [index, node] of nodes.entries()) {
155
+ const local = symbols[index];
156
+ if (!local) continue;
157
+ const name = node.getText(source);
158
+ const shorthand = ast.isShorthandPropertyAssignment(node.parent);
159
+ const used = usedOf(node);
160
+ if (requested.symbols && (used || !usedBindings.has(`${local.id}:${name}`))) {
161
+ const found = types[index];
162
+ const type = found?.isErrorType() ? undefined : found;
163
+ const key = `${local.id}:${type?.id ?? 0}:${name}`;
164
+ if (!candidates.has(key)) candidates.set(key, { name, node, symbol: local, type });
165
+ }
166
+ if (!requested.references || !used) continue;
167
+ const value = shorthand
168
+ ? await native.sdk("getShorthandAssignmentValueSymbol", () =>
169
+ project.checker.getShorthandAssignmentValueSymbol(node.parent),
170
+ )
171
+ : local;
172
+ const target = value ? await targetOf(value) : undefined;
173
+ if (!target) continue;
174
+ for (const declaration of await definitionsOf(target)) {
175
+ const linked = declaration.getSourceFile();
176
+ if (canonicalOf(linked.fileName) === canonicalOf(source.fileName) || !owned(linked.fileName))
177
+ continue;
178
+ let scope = declaration;
179
+ while (
180
+ ast.isBindingElement(scope) ||
181
+ ast.isObjectBindingPattern(scope) ||
182
+ ast.isArrayBindingPattern(scope)
183
+ ) {
184
+ scope = scope.parent;
185
+ }
186
+ // Variable and destructured bindings need the statement's export/const syntax and initializer.
187
+ if (
188
+ ast.isVariableDeclaration(scope) &&
189
+ ast.isVariableDeclarationList(scope.parent) &&
190
+ ast.isVariableStatement(scope.parent.parent)
191
+ ) {
192
+ scope = scope.parent.parent;
193
+ }
194
+ const first = linked.getLineAndCharacterOfPosition(
195
+ scope.getStart(linked, requested.includeComments),
196
+ ).line;
197
+ const last = linked.getLineAndCharacterOfPosition(scope.getEnd() - 1).line + 1;
198
+ const byFile = excerpts.get(name) ?? new Map();
199
+ excerpts.set(name, byFile);
200
+ const pathname = pathnameOf(linked.fileName);
201
+ const entry = byFile.get(pathname) ?? { source: linked, windows: [] };
202
+ byFile.set(pathname, entry);
203
+ entry.windows.push({ first, last });
204
+ }
205
+ }
206
+ const documentation = new Map<string, Promise<string>>();
207
+ const documentationOf = (symbol: Symbol) =>
208
+ once(documentation, bindingKeyOf(symbol), async () => {
209
+ const comment = await native.sdk("getDocumentationCommentOfSymbol", () =>
210
+ project.checker.getDocumentationCommentOfSymbol(symbol),
211
+ );
212
+ const tags = await native.sdk("getJsDocTagsOfSymbol", () =>
213
+ project.checker.getJsDocTagsOfSymbol(symbol),
214
+ );
215
+ return [comment, ...tags.map(({ name, text }) => `**@${name}**${text ? ` ${text}` : ""}`)]
216
+ .filter(Boolean)
217
+ .join("\n\n");
218
+ });
219
+ const formattedTypes = new Map<string, Promise<string>>();
220
+ const descriptions = new Map<
221
+ string,
222
+ Map<string, { types: Set<string>; documentation: string }>
223
+ >();
224
+ const entries = [...candidates.values()];
225
+ for (let first = 0; first < entries.length; first += 8) {
226
+ const jobs = entries.slice(first, first + 8).map(async ({ name, node, symbol, type }) => {
227
+ const binding = bindingKeyOf(symbol);
228
+ const text =
229
+ type === undefined
230
+ ? undefined
231
+ : await once(formattedTypes, JSON.stringify([binding, type.id, name]), () =>
232
+ native.sdk("typeToString", () =>
233
+ project.checker.typeToString(type, node, sdk.TypeFormatFlags.NoTruncation),
234
+ ),
235
+ );
236
+ const target = await targetOf(symbol);
237
+ const docs = target ? await documentationOf(target) : "";
238
+ return { name, binding, text, docs };
239
+ });
240
+ const rendered = await Promise.all(jobs).catch(async (cause) => {
241
+ await native.close();
242
+ // Do not release the transaction while other guarded jobs are still using its snapshot.
243
+ await Promise.allSettled(jobs);
244
+ throw cause;
245
+ });
246
+ for (const { name, binding, text, docs } of rendered) {
247
+ if (!text && !docs) continue;
248
+ const bindings = descriptions.get(name) ?? new Map();
249
+ descriptions.set(name, bindings);
250
+ const entry = bindings.get(binding) ?? { types: new Set<string>(), documentation: docs };
251
+ bindings.set(binding, entry);
252
+ if (text) entry.types.add(text);
253
+ }
254
+ }
255
+ const cleaned = new Map<SourceFile, readonly string[]>();
256
+ const references = sorted(
257
+ [...excerpts].map(
258
+ ([name, byFile]) =>
259
+ [
260
+ name,
261
+ sorted(
262
+ [...byFile].map(([pathname, { source: linked, windows }]) => {
263
+ let lines = cleaned.get(linked);
264
+ if (!lines) {
265
+ const directed = suppressionsOf(linked.text.split("\n")).lines;
266
+ lines = requested.includeComments ? directed : withoutComments(directed, isNote);
267
+ cleaned.set(linked, lines);
268
+ }
269
+ const merged: Array<{ first: number; last: number }> = [];
270
+ for (const window of windows.sort((a, b) => a.first - b.first || a.last - b.last)) {
271
+ const previous = merged.at(-1);
272
+ if (previous && window.first <= previous.last)
273
+ previous.last = Math.max(previous.last, window.last);
274
+ else merged.push({ ...window });
275
+ }
276
+ return [
277
+ pathname,
278
+ merged.map(({ first, last }) => lines.slice(first, last).join("\n")).join("\n…\n"),
279
+ ] as const;
280
+ }),
281
+ ),
282
+ ] as const,
283
+ ),
284
+ );
285
+ return {
286
+ ...(requested.symbols
287
+ ? {
288
+ symbols: sorted(
289
+ [...descriptions].map(
290
+ ([name, bindings]) =>
291
+ [
292
+ name,
293
+ [
294
+ ...new Set(
295
+ [...bindings.values()].map(({ types, documentation }) =>
296
+ [
297
+ ...[...types].map((text) => `\`\`\`typescript\n${text}\n\`\`\``),
298
+ documentation,
299
+ ]
300
+ .filter(Boolean)
301
+ .join("\n\n"),
302
+ ),
303
+ ),
304
+ ].join("\n\n---\n\n"),
305
+ ] as const,
306
+ ),
307
+ ),
308
+ }
309
+ : {}),
310
+ ...(requested.references ? { references } : {}),
311
+ };
312
+ }
313
+
314
+ export const SourceReadsLive = (root?: string) =>
315
+ Layer.effect(SourceReads)(
316
+ Effect.gen(function* () {
317
+ const path = yield* Path.Path;
318
+ const scope = yield* Effect.scope;
319
+ const cwd = root === undefined ? path.resolve() : path.resolve(root);
320
+ const preparedPaths = new Set<string>();
321
+ const reading = yield* Semaphore.make(1);
322
+ const attempt = <A>(work: () => Promise<A>, initializing = false) =>
323
+ Effect.tryPromise({
324
+ try: work,
325
+ catch: (cause) =>
326
+ WalkUnavailable.make({
327
+ message: `Native TypeScript reads unavailable: ${cause instanceof Error ? cause.message : String(cause)}${initializing ? ` Reinstall @drkmttr/adhere with optional platform packages enabled, or install this checkout's development dependencies. Native TypeScript ${packageJson.devDependencies.typescript} is required.` : ""}`,
328
+ }),
329
+ });
330
+ const load = yield* Effect.cached(
331
+ Effect.acquireRelease(
332
+ attempt(async () => {
333
+ const bun = (globalThis as { Bun?: { isStandaloneExecutable?: boolean } }).Bun;
334
+ let manifest = process.env.ADHERE_TYPESCRIPT_PACKAGE;
335
+ const expected = `@typescript/typescript-${process.platform}-${process.arch}`;
336
+ if (!manifest) {
337
+ if (bun?.isStandaloneExecutable)
338
+ throw new Error(
339
+ "This raw executable has no installed compiler. Run the npm/Bun-installed adhere launcher for symbols and references.",
340
+ );
341
+ const require = createRequire(import.meta.url);
342
+ manifest = createRequire(require.resolve("typescript/package.json")).resolve(
343
+ `${expected}/package.json`,
344
+ );
345
+ }
346
+ if (!isAbsolute(manifest))
347
+ throw new Error("The npm launcher must provide an absolute compiler package path.");
348
+ const metadata = Schema.decodeUnknownSync(CompilerPackage)(
349
+ await readFile(manifest, "utf8"),
350
+ );
351
+ if (
352
+ metadata.name !== expected ||
353
+ metadata.version !== packageJson.devDependencies.typescript ||
354
+ !metadata.os.includes(process.platform) ||
355
+ !metadata.cpu.includes(process.arch)
356
+ )
357
+ throw new Error(
358
+ `Expected ${expected}@${packageJson.devDependencies.typescript} for this platform; found ${metadata.name}@${metadata.version}.`,
359
+ );
360
+ const executable = join(
361
+ dirname(manifest),
362
+ "lib",
363
+ process.platform === "win32" ? "tsc.exe" : "tsc",
364
+ );
365
+ await access(executable);
366
+ await access(join(dirname(executable), "lib.d.ts"));
367
+ const ast = await import("typescript/ast");
368
+ const sdk = await import("typescript/async");
369
+ const native = await NativeLsp.open(
370
+ executable,
371
+ cwd,
372
+ packageJson.devDependencies.typescript,
373
+ );
374
+ return { native, ast, sdk };
375
+ }, true),
376
+ ({ native }) => Effect.promise(() => native.close()),
377
+ ).pipe(Effect.provideService(Scope.Scope, scope)),
378
+ );
379
+ return {
380
+ prepare: (files) =>
381
+ Effect.sync(() => {
382
+ for (const file of files) preparedPaths.add(resolve(cwd, file));
383
+ }),
384
+ read: (file, _lines, requested) =>
385
+ !requested.symbols && !requested.references
386
+ ? Effect.succeed({})
387
+ : Effect.suspend(() => {
388
+ const pathname = resolve(cwd, file.path);
389
+ if (preparedPaths.size === 0) preparedPaths.add(pathname);
390
+ if (!preparedPaths.has(pathname))
391
+ return Effect.fail(
392
+ WalkUnavailable.make({
393
+ message: `${file.path} was not prepared; register eligible files before reading.`,
394
+ }),
395
+ );
396
+ // Waiting readers can cancel; only the owner masks interruption during native work.
397
+ return reading.withPermits(1)(
398
+ Effect.uninterruptible(
399
+ Effect.flatMap(load, ({ native, ast, sdk }) =>
400
+ attempt(async () => {
401
+ try {
402
+ await native.openDocument(pathname, file.contents);
403
+ const snapshot = await native.snapshot();
404
+ const project = await native.sdk("getDefaultProjectForFile", () =>
405
+ snapshot.getDefaultProjectForFile(pathname),
406
+ );
407
+ if (!project)
408
+ throw new Error(
409
+ `No native TypeScript project was loaded for ${file.path}.`,
410
+ );
411
+ const result = await readOf(
412
+ native,
413
+ project,
414
+ cwd,
415
+ ast,
416
+ sdk,
417
+ file,
418
+ requested,
419
+ );
420
+ await native.closeDocument(pathname);
421
+ await native.sdk("disposeSnapshot", () => snapshot.dispose());
422
+ return result;
423
+ } catch (cause) {
424
+ // Failure ends this session. API.close owns snapshots under the kill watchdog.
425
+ await native.close();
426
+ throw cause;
427
+ } finally {
428
+ native.api.clearSourceFileCache();
429
+ }
430
+ }),
431
+ ),
432
+ ),
433
+ );
434
+ }),
435
+ };
436
+ }),
437
+ );
@@ -5,18 +5,19 @@ import type { ScannedFile } from "#models/Audit.ts";
5
5
  import { WalkUnavailable } from "#models/Audit.ts";
6
6
  import { walkFiles } from "#walk.ts";
7
7
  import { AdhereConfig } from "#services/AdhereConfig.ts";
8
- import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
8
+ import { clearingStatus, countOf, Status } from "#services/Status.ts";
9
+
10
+ /** A source path and its classification, without retaining its contents. */
11
+ export type SourceFile = Pick<ScannedFile, "path" | "test">;
9
12
 
10
13
  /** Walks the repository's TypeScript source, skipping what is not ours to read. */
11
14
  export class SourceWalker extends Context.Service<
12
15
  SourceWalker,
13
16
  {
14
- /**
15
- * Every TypeScript file under the audit's roots, in directory order, test
16
- * files marked. A file that cannot be read refuses the walk: a silent gap
17
- * would report a clean audit that was never complete.
18
- */
19
- readonly files: Effect.Effect<ReadonlyArray<ScannedFile>, WalkUnavailable>;
17
+ /** Every TypeScript path under the audit's roots, in directory order, with tests marked. */
18
+ readonly files: Effect.Effect<ReadonlyArray<SourceFile>, WalkUnavailable>;
19
+ /** Reads one path on demand. An unreadable file refuses the audit rather than leaving a gap. */
20
+ readonly read: (file: SourceFile) => Effect.Effect<ScannedFile, WalkUnavailable>;
20
21
  }
21
22
  >()("@drkmttr/adhere/services/SourceWalker") {}
22
23
 
@@ -136,50 +137,17 @@ const listRoot = Effect.fn("SourceWalker.listRoot")(function* (
136
137
  return files;
137
138
  });
138
139
 
139
- /** One scannable path, read into lines. */
140
- const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem, path: string) =>
141
- Effect.map(fs.readFileString(path), (content): ScannedFile => ({
142
- path,
140
+ /** One scannable path, read into lines without keeping it in the walker. */
141
+ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem, file: SourceFile) =>
142
+ Effect.map(fs.readFileString(file.path), (content): ScannedFile => ({
143
+ ...file,
143
144
  lines: content.split("\n"),
144
- })).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
145
- );
146
-
147
- /** Reads a root's paths, several at a time, keeping their order, and counts them on the status line. */
148
- const readEach = Effect.fn("SourceWalker.readEach")(function* (
149
- fs: FileSystem.FileSystem,
150
- label: string,
151
- paths: ReadonlyArray<string>,
152
- ) {
153
- const status = yield* Status;
154
- let read = 0;
155
- return yield* Effect.forEach(
156
- paths,
157
- (path: string) =>
158
- readFile(fs, path).pipe(
159
- Effect.tap(() => {
160
- read += 1;
161
- return status.show(
162
- `Reading ${label}: ${counted(read)} of ${countOf(paths.length, "file", "files")}`,
163
- );
164
- }),
165
- ),
166
- { concurrency: 16 },
167
- );
168
- });
169
-
170
- /** One root's files, in path order: read every scannable path. */
171
- const readRoot = Effect.fn("SourceWalker.readRoot")(
172
- (
173
- fs: FileSystem.FileSystem,
174
- path: Path.Path,
175
- root: string,
176
- label: string,
177
- self: string,
178
- keep: Keep,
179
- ) =>
180
- Effect.flatMap(listRoot(fs, path, root, label, self, keep), (paths) =>
181
- readEach(fs, label, paths),
145
+ })).pipe(
146
+ Effect.tap((read) => Effect.logTrace(`read ${file.path}: ${read.lines.length} lines`)),
147
+ Effect.mapError((problem) =>
148
+ WalkUnavailable.make({ message: `${file.path}: ${problem.message}` }),
182
149
  ),
150
+ ),
183
151
  );
184
152
 
185
153
  /**
@@ -199,9 +167,9 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
199
167
 
200
168
  /**
201
169
  * The real walker: the roots walked a directory at a time, skipped trees
202
- * left unread, each file read and split into lines. The run's filter and the
203
- * config's `exclude` leave out what they match. Test files are read, and
204
- * marked, even when no rule judges them, so a prune keeps what other runs
170
+ * left unread, with contents loaded only when the audit asks for a file.
171
+ * The run's filter and the config's `exclude` leave out what they match.
172
+ * Tests are listed even when no rule judges them, so a prune keeps what other runs
205
173
  * know of them. Tests substitute their own tree.
206
174
  * The scan root is the current working directory, so the audit reads the
207
175
  * repository it is run in.
@@ -220,7 +188,7 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
220
188
  const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
221
189
  const trees = yield* clearingStatus(
222
190
  Effect.forEach(dirs, (dir) =>
223
- readRoot(
191
+ listRoot(
224
192
  fs,
225
193
  path,
226
194
  path.join(root, dir),
@@ -232,12 +200,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
232
200
  ).pipe(Effect.mapError(refused)),
233
201
  ),
234
202
  );
235
- return trees
236
- .flat()
237
- .map((file) =>
238
- isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
239
- );
203
+ return trees.flat().map((file): SourceFile => ({
204
+ path: file,
205
+ ...(isTestFile(path.relative(root, file)) ? { test: true } : {}),
206
+ }));
240
207
  });
241
- return SourceWalker.of({ files: walk() });
208
+ return SourceWalker.of({ files: walk(), read: (file) => readFile(fs, file) });
242
209
  }),
243
210
  );