@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.
- package/README.md +273 -130
- package/bin/adhere.js +19 -4
- package/package.json +34 -12
- package/skills/adhere/SKILL.md +180 -31
- package/skills/adhere-setup/SKILL.md +142 -16
- package/src/cli.ts +56 -23
- package/src/config.ts +49 -16
- package/src/index.ts +14 -0
- package/src/markdown.ts +9 -5
- package/src/providers/cloudflare.ts +84 -0
- package/src/providers/jev.ts +66 -0
- package/src/providers/openai.ts +89 -0
- package/src/providers/post.ts +53 -0
- package/src/providers/provider.ts +131 -0
- package/src/providers/providers.test.ts +276 -0
- package/src/providers/systemOne.ts +95 -0
- package/src/services/AdhereConfig.ts +2 -4
- package/src/services/Credentials.ts +101 -51
- package/src/services/Jev.http.ts +165 -170
- package/src/services/Jev.provider.test.ts +207 -0
- package/src/services/Jev.reads.test.ts +439 -0
- package/src/services/Jev.ts +205 -113
- package/src/services/NativeLsp.ts +289 -0
- package/src/services/SourceReads.test.ts +1040 -0
- package/src/services/SourceReads.ts +437 -0
- package/src/services/SourceWalker.ts +26 -59
- package/src/workflows/audit.ts +440 -265
- package/src/workflows/format.ts +74 -17
- package/src/workspaces.test.ts +73 -0
- package/src/workspaces.ts +91 -0
|
@@ -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,
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
141
|
-
Effect.map(fs.readFileString(path), (content): ScannedFile => ({
|
|
142
|
-
|
|
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(
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
203
|
-
* config's `exclude` leave out what they match.
|
|
204
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
.
|
|
238
|
-
|
|
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
|
);
|