@drkmttr/adhere 0.6.0 → 0.6.1
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 +22 -14
- package/package.json +6 -6
- package/src/services/Jev.http.ts +36 -11
- package/src/services/Jev.ts +54 -29
- package/src/workflows/audit.ts +31 -16
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# adhere
|
|
2
2
|
|
|
3
3
|
A linter for rules a normal linter cannot check. A rule is a one-sentence
|
|
4
|
-
description
|
|
4
|
+
description like "business logic should live in services", plus correct
|
|
5
|
+
reference code / code to avoid / or both. For each
|
|
5
6
|
source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
7
|
model) whether the file breaks each rule, gets a calibrated probability per
|
|
7
8
|
rule, and reports the ones above a threshold with the line Jev points at. The
|
|
@@ -9,18 +10,23 @@ report uses the same frame as `vp lint`.
|
|
|
9
10
|
|
|
10
11
|
Deterministic rules (substring matches, type checks) belong in a normal linter.
|
|
11
12
|
|
|
13
|
+
A preset for effect is included which can be run without setup:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
|
|
17
|
+
```
|
|
18
|
+
|
|
12
19
|
## Install
|
|
13
20
|
|
|
14
21
|
```sh
|
|
15
|
-
|
|
22
|
+
bun add --global @drkmttr/adhere # or: npm install --global @drkmttr/adhere
|
|
16
23
|
```
|
|
17
24
|
|
|
18
25
|
To pin the version in a repo, for CI or scripts, add it as a dev dependency
|
|
19
|
-
and run `npx adhere`.
|
|
20
|
-
`npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
|
|
26
|
+
and run `npx adhere`.
|
|
21
27
|
|
|
22
28
|
`adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
|
|
23
|
-
Windows on x64.
|
|
29
|
+
Windows on x64. .
|
|
24
30
|
|
|
25
31
|
adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
|
|
26
32
|
with a TypeSafe AI API key: the one `adhere login` saved, or
|
|
@@ -32,7 +38,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
|
|
|
32
38
|
adhere init # .adhere/config.ts and two example rules
|
|
33
39
|
echo ".adhere/cache/" >> .gitignore
|
|
34
40
|
adhere login # save your TypeSafe AI API key, once
|
|
35
|
-
adhere validate #
|
|
41
|
+
adhere validate # Checks your rules for contradictions using Jev
|
|
36
42
|
adhere lint # audit the working directory
|
|
37
43
|
```
|
|
38
44
|
|
|
@@ -275,13 +281,13 @@ beats all of the above for that rule.
|
|
|
275
281
|
|
|
276
282
|
1. One Jev request per file. The state is the file's numbered lines and
|
|
277
283
|
nothing else. Each rule is one `noul` (yes/no probability) question that
|
|
278
|
-
carries the rule
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
carries the rule's description, reference, and code to avoid: whether the
|
|
285
|
+
file diverges from the reference or, for a rule with only code to avoid,
|
|
286
|
+
whether it contains that code. Criteria for each answer draw the line at
|
|
287
|
+
the rule's scope, so a file with no code the rule is about is a no. Since
|
|
288
|
+
no rule sits in the shared state, a rule's probability depends only on the
|
|
289
|
+
file and that rule, not on which other rules share the request. Every
|
|
290
|
+
question shares the state cost of the request.
|
|
285
291
|
2. A second request only when at least one rule's probability is above its
|
|
286
292
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
287
293
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
@@ -291,7 +297,9 @@ beats all of the above for that rule.
|
|
|
291
297
|
about three bytes a token. When a file's questions would not fit in one
|
|
292
298
|
request, they are split across several. A file whose code and longest
|
|
293
299
|
question would not fit together, or that has more than 5100 lines, is
|
|
294
|
-
skipped and counted in the summary, and the rest of the run goes on.
|
|
300
|
+
skipped and counted in the summary, and the rest of the run goes on. So is
|
|
301
|
+
a file Jev itself counts as over its context, which text denser than the
|
|
302
|
+
estimate, such as CJK, can cause.
|
|
295
303
|
|
|
296
304
|
## Cache
|
|
297
305
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against reference code in .adhere/, judged by Jev.",
|
|
6
6
|
"repository": {
|
|
@@ -67,10 +67,10 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"optionalDependencies": {
|
|
70
|
-
"@drkmttr/adhere-darwin-arm64": "0.6.
|
|
71
|
-
"@drkmttr/adhere-darwin-x64": "0.6.
|
|
72
|
-
"@drkmttr/adhere-linux-arm64": "0.6.
|
|
73
|
-
"@drkmttr/adhere-linux-x64": "0.6.
|
|
74
|
-
"@drkmttr/adhere-win32-x64": "0.6.
|
|
70
|
+
"@drkmttr/adhere-darwin-arm64": "0.6.1",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.6.1",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.6.1",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.6.1",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.6.1"
|
|
75
75
|
}
|
|
76
76
|
}
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
conflictBody,
|
|
8
8
|
contradictBody,
|
|
9
9
|
Jev,
|
|
10
|
+
JevOverflow,
|
|
10
11
|
JevUnavailable,
|
|
11
12
|
judgeBody,
|
|
12
13
|
type Lines,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
requestsOf,
|
|
19
20
|
type Rules,
|
|
20
21
|
} from "#services/Jev.ts";
|
|
21
|
-
import { type Cause, Effect, Layer, Record, Schedule, Schema } from "effect";
|
|
22
|
+
import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
22
23
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
23
24
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
24
25
|
|
|
@@ -39,21 +40,41 @@ const detailOf = (body: string): string => {
|
|
|
39
40
|
return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
/** What Jev answers, with a 400, to a request over its context. */
|
|
44
|
+
const Overflowed = Schema.fromJsonString(
|
|
45
|
+
Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
|
|
46
|
+
);
|
|
47
|
+
const isOverflow = (body: string): boolean =>
|
|
48
|
+
Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
|
-
* A failed
|
|
44
|
-
*
|
|
51
|
+
* A request that failed. A 400 saying the request is over Jev's context is a
|
|
52
|
+
* `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
|
|
53
|
+
* the run with its status and what Jev said, since the status alone does not
|
|
54
|
+
* say why, and a request that got no answer refuses with the cause.
|
|
45
55
|
*/
|
|
46
|
-
const
|
|
56
|
+
const failed = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
|
|
47
57
|
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
48
|
-
return Effect.
|
|
58
|
+
return Effect.fail(refused(`System One request failed: ${problem.message}`));
|
|
49
59
|
}
|
|
50
60
|
const { response } = problem.reason;
|
|
51
|
-
return Effect.
|
|
61
|
+
return Effect.flatMap(
|
|
52
62
|
Effect.orElseSucceed(response.text, () => ""),
|
|
53
|
-
(body)
|
|
63
|
+
(body): Effect.Effect<never, JevOverflow | JevUnavailable> =>
|
|
64
|
+
response.status === 400 && isOverflow(body)
|
|
65
|
+
? Effect.fail(JevOverflow.make())
|
|
66
|
+
: Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
|
|
54
67
|
);
|
|
55
68
|
};
|
|
56
69
|
|
|
70
|
+
/** Comparing rules has no file to skip, so a request over Jev's context refuses instead. */
|
|
71
|
+
const refuseOverflow = <A>(
|
|
72
|
+
self: Effect.Effect<A, JevOverflow | JevUnavailable>,
|
|
73
|
+
): Effect.Effect<A, JevUnavailable> =>
|
|
74
|
+
Effect.catchTag(self, "JevOverflow", () =>
|
|
75
|
+
Effect.fail(refused("Comparing the rules took a request over Jev's context")),
|
|
76
|
+
);
|
|
77
|
+
|
|
57
78
|
export const JevLive = Layer.effect(Jev)(
|
|
58
79
|
Effect.gen(function* () {
|
|
59
80
|
const config = yield* AdhereConfig;
|
|
@@ -79,7 +100,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
79
100
|
).pipe(Effect.orDie);
|
|
80
101
|
const response = yield* client
|
|
81
102
|
.execute(HttpClientRequest.bearerToken(request, apiKey))
|
|
82
|
-
.pipe(Effect.catch(
|
|
103
|
+
.pipe(Effect.catch(failed));
|
|
83
104
|
return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
|
|
84
105
|
Effect.mapError((problem) =>
|
|
85
106
|
refused(`System One response did not decode: ${problem.message}`),
|
|
@@ -127,7 +148,10 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
127
148
|
rules: ReadonlyArray<ComparedRule>,
|
|
128
149
|
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
129
150
|
) {
|
|
130
|
-
const answers = yield* answersTo(
|
|
151
|
+
const answers = yield* answersTo(
|
|
152
|
+
conflictBody(config.model, rules, partners),
|
|
153
|
+
ChoiceAnswers,
|
|
154
|
+
).pipe(refuseOverflow);
|
|
131
155
|
return namedPairs(answers, partners);
|
|
132
156
|
});
|
|
133
157
|
|
|
@@ -138,8 +162,9 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
138
162
|
return yield* Effect.forEach(
|
|
139
163
|
pairs,
|
|
140
164
|
(pair) =>
|
|
141
|
-
Effect.map(
|
|
142
|
-
|
|
165
|
+
Effect.map(
|
|
166
|
+
ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(refuseOverflow),
|
|
167
|
+
({ answers }) => pairProbability(answers, pair),
|
|
143
168
|
),
|
|
144
169
|
{ concurrency: 8 },
|
|
145
170
|
);
|
package/src/services/Jev.ts
CHANGED
|
@@ -8,17 +8,23 @@ export class JevUnavailable extends Schema.TaggedError<JevUnavailable>()("JevUna
|
|
|
8
8
|
message: Schema.String,
|
|
9
9
|
}) {}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Jev's refusal of a request over its context. `fits` keeps such files from
|
|
13
|
+
* being sent, but by an estimate; this is Jev's own count disagreeing.
|
|
14
|
+
*/
|
|
15
|
+
export class JevOverflow extends Schema.TaggedError<JevOverflow>()("JevOverflow", {}) {}
|
|
16
|
+
|
|
11
17
|
export class Jev extends Context.Service<
|
|
12
18
|
Jev,
|
|
13
19
|
{
|
|
14
20
|
readonly judge: (
|
|
15
21
|
lines: Lines,
|
|
16
22
|
rules: Rules,
|
|
17
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
|
|
23
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
18
24
|
readonly locate: (
|
|
19
25
|
lines: Lines,
|
|
20
26
|
rules: Rules,
|
|
21
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
|
|
27
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
22
28
|
/**
|
|
23
29
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
24
30
|
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
@@ -66,8 +72,9 @@ const encoder = new TextEncoder();
|
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
74
|
* An estimate, meant to run high, of the tokens Jev reads for a value: one
|
|
69
|
-
* per three bytes of its JSON.
|
|
70
|
-
*
|
|
75
|
+
* per three bytes of its JSON. Jev 1.13 reads numbered TypeScript at 3.2
|
|
76
|
+
* bytes a token, and this ran 12 to 16 percent high on the eval's requests.
|
|
77
|
+
* Text that packs more tokens into its bytes, such as CJK, can outrun it.
|
|
71
78
|
*/
|
|
72
79
|
export const tokensOf = (value: unknown): number =>
|
|
73
80
|
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
@@ -84,9 +91,12 @@ const stateOf = (lines: Lines) => ({
|
|
|
84
91
|
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
85
92
|
});
|
|
86
93
|
|
|
87
|
-
/**
|
|
88
|
-
const
|
|
89
|
-
|
|
94
|
+
/** The rule as its questions carry it: the description, and its code on either side. */
|
|
95
|
+
const ruleFields = (rule: Rule) => ({
|
|
96
|
+
rule: rule.description,
|
|
97
|
+
...(rule.reference === undefined ? {} : { reference: rule.reference }),
|
|
98
|
+
...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
|
|
99
|
+
});
|
|
90
100
|
|
|
91
101
|
/** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
|
|
92
102
|
const ruleState = ({ description, reference, avoid }: Rule) => ({
|
|
@@ -112,28 +122,43 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
|
|
|
112
122
|
|
|
113
123
|
/**
|
|
114
124
|
* Whether the file breaks the rule. The rule rides in the question, not the
|
|
115
|
-
* state, so each question reads its own rule and no other.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
125
|
+
* state, so each question reads its own rule and no other. A rule with a
|
|
126
|
+
* reference asks whether the file diverges from it, with the code to avoid
|
|
127
|
+
* as an example when there is some. A rule without one asks whether the file
|
|
128
|
+
* contains the code to avoid. The criteria draw the line at the rule's scope:
|
|
129
|
+
* a file with no code the rule is about is a no.
|
|
118
130
|
*/
|
|
119
|
-
export const judgeQuestion = (rule: Rule) =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
131
|
+
export const judgeQuestion = (rule: Rule) => {
|
|
132
|
+
const example = rule.avoid === undefined ? "" : ", for example by doing what `avoid` shows";
|
|
133
|
+
const [question, yes, no] =
|
|
134
|
+
rule.reference === undefined
|
|
135
|
+
? [
|
|
136
|
+
"Does `code` contain the pattern shown in `avoid`, which `rule` rules out? Answer no if nothing in this file resembles it.",
|
|
137
|
+
"`code` contains the pattern shown in `avoid`",
|
|
138
|
+
"`code` has nothing like `avoid`",
|
|
139
|
+
]
|
|
140
|
+
: [
|
|
141
|
+
`Does \`code\` diverge from the pattern shown in \`reference\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
|
|
142
|
+
`\`code\` diverges from the pattern shown in \`reference\`${example}`,
|
|
143
|
+
"`code` follows the pattern",
|
|
144
|
+
];
|
|
145
|
+
return {
|
|
146
|
+
type: "noul" as const,
|
|
147
|
+
instructions: { question, ...ruleFields(rule) },
|
|
148
|
+
criteria: {
|
|
149
|
+
true: `${yes}, in code that \`rule\` is about`,
|
|
150
|
+
false: `${no}, or has no code that \`rule\` is about`,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
130
154
|
|
|
131
|
-
/**
|
|
132
|
-
const
|
|
133
|
-
rule
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
/** What the offending line does, for the line and block questions. */
|
|
156
|
+
const offense = (rule: Rule): string =>
|
|
157
|
+
rule.reference === undefined
|
|
158
|
+
? "shows the pattern in `avoid`"
|
|
159
|
+
: rule.avoid === undefined
|
|
160
|
+
? "diverges from `reference`"
|
|
161
|
+
: "diverges from `reference` or resembles `avoid`";
|
|
137
162
|
|
|
138
163
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
139
164
|
const criteria: Record<string, string> = {};
|
|
@@ -163,7 +188,7 @@ export const locateBody = (
|
|
|
163
188
|
return {
|
|
164
189
|
type: "choice" as const,
|
|
165
190
|
instructions: {
|
|
166
|
-
question:
|
|
191
|
+
question: `Which line of \`code\` most clearly ${offense(rule)}?`,
|
|
167
192
|
...ruleFields(rule),
|
|
168
193
|
},
|
|
169
194
|
criteria:
|
|
@@ -188,7 +213,7 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
188
213
|
questions: Record.map(rules, (rule) => ({
|
|
189
214
|
type: "choice" as const,
|
|
190
215
|
instructions: {
|
|
191
|
-
question:
|
|
216
|
+
question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
|
|
192
217
|
...ruleFields(rule),
|
|
193
218
|
},
|
|
194
219
|
criteria,
|
package/src/workflows/audit.ts
CHANGED
|
@@ -98,12 +98,16 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
|
|
|
98
98
|
const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
|
|
99
99
|
|
|
100
100
|
/** A refusal that names the file it stopped at. Files finished before it are already cached. */
|
|
101
|
-
const inFile =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
const inFile =
|
|
102
|
+
(file: ScannedFile) =>
|
|
103
|
+
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
104
|
+
Effect.mapError(self, (problem) =>
|
|
105
|
+
problem instanceof JevUnavailable
|
|
106
|
+
? JevUnavailable.make({
|
|
107
|
+
message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
|
|
108
|
+
})
|
|
109
|
+
: problem,
|
|
110
|
+
);
|
|
107
111
|
|
|
108
112
|
export const planAudit: Effect.Effect<
|
|
109
113
|
AuditPlan,
|
|
@@ -181,18 +185,14 @@ export const executeAudit = (
|
|
|
181
185
|
const jev = yield* Jev;
|
|
182
186
|
const cache = yield* AuditCache;
|
|
183
187
|
|
|
184
|
-
|
|
188
|
+
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
189
|
+
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
185
190
|
file,
|
|
186
|
-
skipped,
|
|
187
191
|
hash,
|
|
188
192
|
prepared,
|
|
189
193
|
kept,
|
|
190
194
|
pending,
|
|
191
195
|
}: FilePlan) {
|
|
192
|
-
if (skipped) {
|
|
193
|
-
yield* progress({ requests: 0, findings: 0 });
|
|
194
|
-
return fileResult("skipped", []);
|
|
195
|
-
}
|
|
196
196
|
const probabilities = isEmpty(pending)
|
|
197
197
|
? {}
|
|
198
198
|
: yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
|
|
@@ -244,13 +244,28 @@ export const executeAudit = (
|
|
|
244
244
|
: [];
|
|
245
245
|
})
|
|
246
246
|
.sort((a, b) => a.line - b.line);
|
|
247
|
-
|
|
247
|
+
return {
|
|
248
|
+
result: fileResult(cached ? "cached" : "judged", findings),
|
|
248
249
|
requests:
|
|
249
250
|
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
250
251
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
|
|
256
|
+
const { result, requests } = plan.skipped
|
|
257
|
+
? { result: fileResult("skipped", []), requests: 0 }
|
|
258
|
+
: yield* judgeFile(plan).pipe(
|
|
259
|
+
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
260
|
+
Effect.catchTag("JevOverflow", () =>
|
|
261
|
+
Effect.succeed({
|
|
262
|
+
result: fileResult("skipped", []),
|
|
263
|
+
requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
yield* progress({ requests, findings: result.findings.length });
|
|
268
|
+
return result;
|
|
254
269
|
});
|
|
255
270
|
|
|
256
271
|
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|