@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
package/src/workflows/audit.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { isNote, withoutComments } from "#comments.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type Examples,
|
|
4
|
+
examplesOf,
|
|
5
|
+
type Level,
|
|
6
|
+
type ReadState,
|
|
7
|
+
type Rule,
|
|
8
|
+
type RuleId,
|
|
9
|
+
} from "#config.ts";
|
|
3
10
|
import { type Excerpt, type Range } from "#excerpt.ts";
|
|
4
|
-
import type
|
|
11
|
+
import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
5
12
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
6
13
|
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
7
14
|
import {
|
|
@@ -19,13 +26,15 @@ import {
|
|
|
19
26
|
questionRoom,
|
|
20
27
|
tokensOf,
|
|
21
28
|
} from "#services/Jev.ts";
|
|
22
|
-
import {
|
|
29
|
+
import { type SourceReadRequest, SourceReads } from "#services/SourceReads.ts";
|
|
30
|
+
import { type SourceFile, SourceWalker } from "#services/SourceWalker.ts";
|
|
23
31
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
24
32
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
25
33
|
import { priceOf } from "#pricing.ts";
|
|
26
34
|
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
35
|
+
import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
36
|
+
import { workspacePackagesIn } from "#workspaces.ts";
|
|
37
|
+
import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
|
|
29
38
|
|
|
30
39
|
export interface Finding {
|
|
31
40
|
readonly rule: RuleId;
|
|
@@ -65,7 +74,7 @@ export interface AuditResult {
|
|
|
65
74
|
readonly judged: number;
|
|
66
75
|
readonly cached: number;
|
|
67
76
|
readonly skipped: number;
|
|
68
|
-
/** Files whose
|
|
77
|
+
/** Files whose judge or cached-locate requests `--limit` left for a later run. */
|
|
69
78
|
readonly waiting: number;
|
|
70
79
|
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
71
80
|
readonly blocked: ReadonlyArray<Blocked>;
|
|
@@ -92,6 +101,7 @@ interface PreparedRule {
|
|
|
92
101
|
readonly rule: Rule;
|
|
93
102
|
readonly fingerprint: string;
|
|
94
103
|
readonly threshold: number;
|
|
104
|
+
|
|
95
105
|
/** The preset the rule came from, when it is not the project's own. */
|
|
96
106
|
readonly preset?: string;
|
|
97
107
|
}
|
|
@@ -113,23 +123,14 @@ const LAYOUT = "sections";
|
|
|
113
123
|
* matcher re-judges the rule, and a question asked differently re-judges
|
|
114
124
|
* every rule. So does an edited `appendState`, by its source; what the hook
|
|
115
125
|
* reads outside the file is not known until it runs, so a change there does
|
|
116
|
-
* not.
|
|
117
|
-
* package added, renamed, or moved re-judges that rule and no other.
|
|
126
|
+
* not. Shared read context and related files do not invalidate judgments.
|
|
118
127
|
*/
|
|
119
|
-
const fingerprintOf = (
|
|
120
|
-
model: string,
|
|
121
|
-
rule: Rule,
|
|
122
|
-
workspacePackages: Readonly<Record<string, string>> = {},
|
|
123
|
-
) => {
|
|
128
|
+
const fingerprintOf = (model: string, rule: Rule) => {
|
|
124
129
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
125
130
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
126
131
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
127
|
-
const packages =
|
|
128
|
-
rule.reads?.includes("workspacePackages") === true
|
|
129
|
-
? `\u0000${JSON.stringify(workspacePackages)}`
|
|
130
|
-
: "";
|
|
131
132
|
return sha256(
|
|
132
|
-
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}
|
|
133
|
+
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
133
134
|
);
|
|
134
135
|
};
|
|
135
136
|
|
|
@@ -146,7 +147,7 @@ const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerp
|
|
|
146
147
|
const MATCHER_THRESHOLD = 0.5;
|
|
147
148
|
|
|
148
149
|
/** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
|
|
149
|
-
const inScope = (judgment: Judgment): boolean =>
|
|
150
|
+
export const inScope = (judgment: Pick<Judgment, "appliesTo" | "excludeIf">): boolean =>
|
|
150
151
|
(judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
|
|
151
152
|
!(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
|
|
152
153
|
|
|
@@ -165,19 +166,19 @@ const scopeMisses = (rule: Rule, judgment: Judgment): string =>
|
|
|
165
166
|
|
|
166
167
|
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
167
168
|
export interface FilePlan {
|
|
168
|
-
/**
|
|
169
|
-
readonly file:
|
|
170
|
-
/**
|
|
171
|
-
readonly original: ReadonlyArray<string>;
|
|
172
|
-
/** Too long for Jev: counted, not judged. */
|
|
169
|
+
/** Only the path and classification; contents are loaded in bounded passes, never kept here. */
|
|
170
|
+
readonly file: SourceFile;
|
|
171
|
+
/** Selected work is known to overflow the cheap budget; deferred files may be unbudgeted. */
|
|
173
172
|
readonly skipped: boolean;
|
|
174
173
|
readonly hash: string;
|
|
174
|
+
/** Only this file's imported workspace packages; native reads stay local to send time. */
|
|
175
|
+
readonly readState: ReadState;
|
|
175
176
|
readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
|
|
176
177
|
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
177
178
|
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
178
179
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
179
|
-
/**
|
|
180
|
-
readonly deferred:
|
|
180
|
+
/** The file's entire request workload, including cached locating, waits for a later run. */
|
|
181
|
+
readonly deferred: boolean;
|
|
181
182
|
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
182
183
|
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
183
184
|
/** What the file's `adhere-ignore` comments suppress. */
|
|
@@ -187,27 +188,37 @@ export interface FilePlan {
|
|
|
187
188
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
188
189
|
export interface AuditPlan {
|
|
189
190
|
readonly files: ReadonlyArray<FilePlan>;
|
|
191
|
+
/** The normalization used for content keys, also used when reloading a selected file. */
|
|
192
|
+
readonly includeComments?: boolean;
|
|
193
|
+
/** Explicit --limit 0 intent: no native resolution, judging, or locating, even for cached flags. */
|
|
194
|
+
readonly planOnly?: boolean;
|
|
195
|
+
/** Requested native context, not its results. Only execution discovers and uses SourceReads. */
|
|
196
|
+
readonly nativeReads?: SourceReadRequest;
|
|
190
197
|
/**
|
|
191
198
|
* The content hashes of files read that no rule in the run judges, such as
|
|
192
199
|
* tests when no rule says `tests`: a prune keeps what other runs know of them.
|
|
193
200
|
*/
|
|
194
201
|
readonly unjudged: ReadonlyArray<string>;
|
|
195
|
-
/** Rules
|
|
202
|
+
/** Rules applying to files not known to be skipped, including unbudgeted deferred files. */
|
|
196
203
|
readonly rules: number;
|
|
197
|
-
/**
|
|
204
|
+
/** File/rule pairs not known to be skipped; deferred work stays counted until budgeted. */
|
|
198
205
|
readonly checks: number;
|
|
199
206
|
/** Checks a cached judgment answers. */
|
|
200
207
|
readonly cached: number;
|
|
201
|
-
/** Files
|
|
208
|
+
/** Files whose selected work is known to exceed the cheap budget. */
|
|
202
209
|
readonly skipped: number;
|
|
203
|
-
/**
|
|
210
|
+
/** Files whose judge or cached-locate requests `--limit` leaves for a later run. */
|
|
204
211
|
readonly deferred: number;
|
|
205
|
-
/**
|
|
212
|
+
/** Judge requests estimated from code and cheap context; native reads may split or skip at send time. */
|
|
206
213
|
readonly requests: number;
|
|
207
|
-
/**
|
|
214
|
+
/** Cheap-context request estimate for locating known cached flags; omitted means zero. */
|
|
215
|
+
readonly cachedLocateRequests?: number;
|
|
216
|
+
/** Estimated judge input tokens, excluding native reads and locating findings. */
|
|
208
217
|
readonly tokens: number;
|
|
209
218
|
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
210
219
|
readonly price?: number;
|
|
220
|
+
/** The model a config's provider asks in place of Jev, which the plan names instead. */
|
|
221
|
+
readonly provider?: string;
|
|
211
222
|
/** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
|
|
212
223
|
readonly threshold: number;
|
|
213
224
|
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
@@ -224,9 +235,62 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
|
|
|
224
235
|
|
|
225
236
|
const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
|
|
226
237
|
|
|
238
|
+
/** Flagged judgments whose section or sufficiency still needs a locate request. */
|
|
239
|
+
const unlocated = (
|
|
240
|
+
prepared: Readonly<Record<RuleId, PreparedRule>>,
|
|
241
|
+
judged: Readonly<Record<RuleId, Judgment>>,
|
|
242
|
+
) =>
|
|
243
|
+
Record.filter(prepared, (k, id) => {
|
|
244
|
+
const judgment = judged[id];
|
|
245
|
+
return (
|
|
246
|
+
judgment !== undefined &&
|
|
247
|
+
judgment.probability > k.threshold &&
|
|
248
|
+
inScope(judgment) &&
|
|
249
|
+
(judgment.section === undefined || judgment.sufficiency === undefined)
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const needsRequests = (plan: FilePlan): boolean =>
|
|
254
|
+
!plan.skipped &&
|
|
255
|
+
!plan.deferred &&
|
|
256
|
+
(!isEmpty(plan.pending) || !isEmpty(unlocated(plan.prepared, plan.kept)));
|
|
257
|
+
|
|
258
|
+
/** Located findings survive skipped new work; suppression is applied by the file workflow. */
|
|
259
|
+
const findingsOf = (
|
|
260
|
+
{ file, prepared }: FilePlan,
|
|
261
|
+
judged: Readonly<Record<RuleId, Judgment>>,
|
|
262
|
+
original: ReadonlyArray<string>,
|
|
263
|
+
): ReadonlyArray<Finding> =>
|
|
264
|
+
Object.entries(judged)
|
|
265
|
+
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
266
|
+
const k = prepared[id];
|
|
267
|
+
return k !== undefined &&
|
|
268
|
+
judgment.section !== undefined &&
|
|
269
|
+
judgment.probability > k.threshold &&
|
|
270
|
+
inScope(judgment)
|
|
271
|
+
? [
|
|
272
|
+
{
|
|
273
|
+
rule: id,
|
|
274
|
+
...(k.preset === undefined ? {} : { preset: k.preset }),
|
|
275
|
+
description: k.rule.description,
|
|
276
|
+
examples: examplesOf(k.rule),
|
|
277
|
+
file: file.path,
|
|
278
|
+
section: judgment.section,
|
|
279
|
+
...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
|
|
280
|
+
excerpt: excerptOf(original, judgment.section),
|
|
281
|
+
...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
|
|
282
|
+
probability: judgment.probability,
|
|
283
|
+
threshold: k.threshold,
|
|
284
|
+
level: k.rule.level ?? "error",
|
|
285
|
+
},
|
|
286
|
+
]
|
|
287
|
+
: [];
|
|
288
|
+
})
|
|
289
|
+
.sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
|
|
290
|
+
|
|
227
291
|
/** A refusal that names the file it stopped at. Files finished before it are already cached. */
|
|
228
292
|
const inFile =
|
|
229
|
-
(file:
|
|
293
|
+
(file: SourceFile) =>
|
|
230
294
|
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
231
295
|
Effect.mapError(self, (problem) =>
|
|
232
296
|
problem instanceof JevUnavailable
|
|
@@ -236,29 +300,68 @@ const inFile =
|
|
|
236
300
|
: problem,
|
|
237
301
|
);
|
|
238
302
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
303
|
+
/** Preserve line numbers while blanking directives, and comments other than @adhere notes. */
|
|
304
|
+
const normalized = (original: ReadonlyArray<string>, includeComments: boolean) => {
|
|
305
|
+
const { lines, suppressions } = suppressionsOf(original);
|
|
306
|
+
return {
|
|
307
|
+
lines: includeComments ? lines : withoutComments(lines, isNote),
|
|
308
|
+
suppressions,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
interface LoadedFile {
|
|
313
|
+
readonly file: ScannedFile;
|
|
314
|
+
readonly original: ReadonlyArray<string>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Ignore directives are absent from the code key, but their parsed policy still belongs to the plan. */
|
|
318
|
+
const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
|
|
319
|
+
if (left === right) return true;
|
|
320
|
+
const sameRules = (a: ReadonlySet<string>, b: ReadonlySet<string>) =>
|
|
321
|
+
a.size === b.size && [...a].every((rule) => b.has(rule));
|
|
322
|
+
return (
|
|
323
|
+
sameRules(left.file, right.file) &&
|
|
324
|
+
left.statements.length === right.statements.length &&
|
|
325
|
+
left.statements.every((statement, index) => {
|
|
326
|
+
const other = right.statements[index]!;
|
|
327
|
+
return (
|
|
328
|
+
statement.first === other.first &&
|
|
329
|
+
statement.last === other.last &&
|
|
330
|
+
sameRules(statement.rules, other.rules)
|
|
331
|
+
);
|
|
332
|
+
})
|
|
333
|
+
);
|
|
258
334
|
};
|
|
259
335
|
|
|
336
|
+
/** Validate code and ignore policy before sending a request or showing a cached excerpt. */
|
|
337
|
+
const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
|
|
338
|
+
const original = yield* (yield* SourceWalker).read(plan.file);
|
|
339
|
+
const { lines, suppressions } = normalized(original.lines, includeComments);
|
|
340
|
+
if (
|
|
341
|
+
(yield* sha256(lines.join("\n"))) !== plan.hash ||
|
|
342
|
+
!sameSuppressions(plan.suppressions, suppressions)
|
|
343
|
+
) {
|
|
344
|
+
return yield* WalkUnavailable.make({
|
|
345
|
+
message: `${plan.file.path}: source changed since planning. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** A cached finding needs the source again even when no request will be sent. */
|
|
352
|
+
const hasLocatedFindings = (plan: FilePlan): boolean =>
|
|
353
|
+
Object.entries(plan.kept).some(([id, judgment]) => {
|
|
354
|
+
const prepared = plan.prepared[id];
|
|
355
|
+
return (
|
|
356
|
+
prepared !== undefined &&
|
|
357
|
+
judgment.section !== undefined &&
|
|
358
|
+
judgment.probability > prepared.threshold &&
|
|
359
|
+
inScope(judgment)
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
260
363
|
export interface PlanOptions {
|
|
261
|
-
/**
|
|
364
|
+
/** Select at most this many files needing judge or cached-locate requests; zero plans only. */
|
|
262
365
|
readonly limit?: number | undefined;
|
|
263
366
|
}
|
|
264
367
|
|
|
@@ -272,72 +375,69 @@ export const planAudit = (
|
|
|
272
375
|
Effect.gen(function* () {
|
|
273
376
|
const config = yield* AdhereConfig;
|
|
274
377
|
const cache = yield* AuditCache;
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
378
|
+
const workspacePackages = config.reads.includes("workspacePackages")
|
|
379
|
+
? (config.workspacePackages ?? {})
|
|
380
|
+
: undefined;
|
|
381
|
+
const nativeReads: SourceReadRequest = {
|
|
382
|
+
symbols: config.reads.includes("symbols"),
|
|
383
|
+
references: config.reads.includes("references"),
|
|
384
|
+
includeComments: config.includeComments === true,
|
|
385
|
+
};
|
|
386
|
+
const walker = yield* SourceWalker;
|
|
387
|
+
const files = yield* walker.files;
|
|
388
|
+
|
|
389
|
+
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
390
|
+
const presetOfRule = new Map(
|
|
391
|
+
(config.scopedRules ?? []).flatMap((entry) =>
|
|
392
|
+
entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
|
|
393
|
+
),
|
|
394
|
+
);
|
|
291
395
|
|
|
292
396
|
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
293
397
|
// judge a test, and none that an adhere-ignore-file comment names.
|
|
294
|
-
const configuredRules = (file:
|
|
398
|
+
const configuredRules = (file: SourceFile, suppressions: Suppressions) =>
|
|
295
399
|
Record.filter(
|
|
296
400
|
config.scopedRules === undefined
|
|
297
401
|
? config.rules
|
|
298
402
|
: applicableRules(file.path, config.scopedRules),
|
|
299
403
|
(rule, id) =>
|
|
300
404
|
judgesFile(rule, file.test === true) &&
|
|
301
|
-
!
|
|
405
|
+
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })),
|
|
302
406
|
);
|
|
303
407
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
|
|
311
|
-
return {
|
|
312
|
-
file,
|
|
313
|
-
skipped: true,
|
|
314
|
-
hash,
|
|
315
|
-
prepared: {},
|
|
316
|
-
kept: {},
|
|
317
|
-
pending: {},
|
|
318
|
-
deferred: 0,
|
|
319
|
-
sampled: {},
|
|
320
|
-
suppressions: suppressionsIn(file),
|
|
321
|
-
original: originalOf(file),
|
|
322
|
-
} satisfies FilePlan;
|
|
323
|
-
}
|
|
324
|
-
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
325
|
-
Object.entries(rules),
|
|
326
|
-
([id, rule]) =>
|
|
327
|
-
Effect.map(fingerprintOf(config.model, rule, config.workspacePackages), (fingerprint) => {
|
|
408
|
+
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
409
|
+
const preparedRules = new Map<Rule, PreparedRule>(
|
|
410
|
+
yield* Effect.forEach(
|
|
411
|
+
new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
|
|
412
|
+
(rule) =>
|
|
413
|
+
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
328
414
|
const preset = presetOfRule.get(rule);
|
|
329
415
|
return [
|
|
330
|
-
|
|
331
|
-
{
|
|
416
|
+
rule,
|
|
417
|
+
Object.freeze({
|
|
332
418
|
rule,
|
|
333
419
|
fingerprint,
|
|
334
420
|
threshold: rule.threshold ?? config.threshold,
|
|
335
421
|
...(preset === undefined ? {} : { preset }),
|
|
336
|
-
},
|
|
422
|
+
}),
|
|
337
423
|
] as const;
|
|
338
424
|
}),
|
|
339
|
-
)
|
|
340
|
-
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
|
|
429
|
+
const original = yield* walker.read(file);
|
|
430
|
+
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
431
|
+
const rules = configuredRules(file, suppressions);
|
|
432
|
+
const code = lines.join("\n");
|
|
433
|
+
const readState: ReadState =
|
|
434
|
+
workspacePackages === undefined
|
|
435
|
+
? {}
|
|
436
|
+
: { workspacePackages: workspacePackagesIn(code, workspacePackages) };
|
|
437
|
+
// Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
|
|
438
|
+
const hash = yield* sha256(code);
|
|
439
|
+
const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
|
|
440
|
+
const answers = isEmpty(prepared) ? {} : yield* cache.get(hash, file.path);
|
|
341
441
|
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
342
442
|
const answer = answers[fingerprint];
|
|
343
443
|
return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
|
|
@@ -347,25 +447,22 @@ export const planAudit = (
|
|
|
347
447
|
`plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
|
|
348
448
|
);
|
|
349
449
|
return {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
450
|
+
lines,
|
|
451
|
+
plan: {
|
|
452
|
+
file,
|
|
453
|
+
skipped: false,
|
|
454
|
+
hash,
|
|
455
|
+
readState,
|
|
456
|
+
prepared,
|
|
457
|
+
kept,
|
|
458
|
+
pending,
|
|
459
|
+
deferred: false,
|
|
460
|
+
sampled: {},
|
|
461
|
+
suppressions,
|
|
462
|
+
} satisfies FilePlan,
|
|
463
|
+
};
|
|
361
464
|
});
|
|
362
465
|
|
|
363
|
-
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
364
|
-
const presetOfRule = new Map(
|
|
365
|
-
(config.scopedRules ?? []).flatMap((entry) =>
|
|
366
|
-
entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
|
|
367
|
-
),
|
|
368
|
-
);
|
|
369
466
|
// Preset rules are not the project's to change, so the linter check leaves them out.
|
|
370
467
|
const presetRules = new Set(
|
|
371
468
|
(config.scopedRules ?? []).flatMap((entry) =>
|
|
@@ -373,33 +470,102 @@ export const planAudit = (
|
|
|
373
470
|
),
|
|
374
471
|
);
|
|
375
472
|
|
|
473
|
+
// Ordered prefetch keeps only a bounded number of normalized files alive. Select whole
|
|
474
|
+
// request-bearing files before budgeting them; cheap overflow leaves the slot for the next path.
|
|
475
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
476
|
+
const limited: Array<FilePlan> = [];
|
|
477
|
+
const unjudged: Array<string> = [];
|
|
478
|
+
const rooms = new Map<FilePlan, number>();
|
|
479
|
+
// Undefined marks a full tally, avoiding repeated lookups without retaining its file map.
|
|
480
|
+
const needs = new Map<
|
|
481
|
+
Rule,
|
|
482
|
+
{ readonly key: string; readonly tallied: Tally["files"] } | undefined
|
|
483
|
+
>();
|
|
484
|
+
const baseLoads = new Map<
|
|
485
|
+
SourceFile,
|
|
486
|
+
{ readonly requests: number; readonly tokens: number; readonly locating: number }
|
|
487
|
+
>();
|
|
488
|
+
const status = yield* Status;
|
|
489
|
+
let left = options.limit ?? Infinity;
|
|
490
|
+
let done = 0;
|
|
491
|
+
yield* clearingStatus(
|
|
492
|
+
Stream.fromIterable(sorted).pipe(
|
|
493
|
+
Stream.mapEffect(planFile, { concurrency: 8 }),
|
|
494
|
+
Stream.runForEach(({ plan, lines }) =>
|
|
495
|
+
Effect.gen(function* () {
|
|
496
|
+
if (isEmpty(plan.prepared)) {
|
|
497
|
+
unjudged.push(plan.hash);
|
|
498
|
+
} else {
|
|
499
|
+
const pending = plan.pending;
|
|
500
|
+
const flagged = unlocated(plan.prepared, plan.kept);
|
|
501
|
+
const checking = { ...pending, ...flagged };
|
|
502
|
+
if (isEmpty(checking)) {
|
|
503
|
+
limited.push(plan);
|
|
504
|
+
} else if (left <= 0) {
|
|
505
|
+
limited.push({ ...plan, pending: {}, deferred: true });
|
|
506
|
+
} else if (!fits(lines, rulesOf(checking), plan.readState)) {
|
|
507
|
+
limited.push({ ...plan, skipped: true, pending: {} });
|
|
508
|
+
} else {
|
|
509
|
+
left -= 1;
|
|
510
|
+
const selected = plan;
|
|
511
|
+
limited.push(selected);
|
|
512
|
+
if (!isEmpty(checking)) {
|
|
513
|
+
// Exact base estimates while the code is already loaded; retain only scalars.
|
|
514
|
+
const load = isEmpty(pending)
|
|
515
|
+
? { requests: 0, tokens: 0 }
|
|
516
|
+
: judgeLoad(lines, rulesOf(pending), [], plan.readState);
|
|
517
|
+
baseLoads.set(selected.file, {
|
|
518
|
+
...load,
|
|
519
|
+
locating: isEmpty(flagged)
|
|
520
|
+
? 0
|
|
521
|
+
: locateRequests(lines, rulesOf(flagged), plan.readState),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
let candidate = false;
|
|
525
|
+
for (const { rule } of Object.values(pending)) {
|
|
526
|
+
if (presetRules.has(rule)) continue;
|
|
527
|
+
if (!needs.has(rule)) {
|
|
528
|
+
const key = yield* tallyKeyOf(config.model, rule);
|
|
529
|
+
const tallied = (yield* cache.tally(key))?.files ?? {};
|
|
530
|
+
needs.set(
|
|
531
|
+
rule,
|
|
532
|
+
Object.keys(tallied).length >= SAMPLE ? undefined : { key, tallied },
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const need = needs.get(rule);
|
|
536
|
+
if (need !== undefined && need.tallied[selected.file.path] === undefined)
|
|
537
|
+
candidate = true;
|
|
538
|
+
}
|
|
539
|
+
if (candidate) rooms.set(selected, questionRoom(lines, plan.readState));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
done += 1;
|
|
543
|
+
yield* status.show(
|
|
544
|
+
`Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
|
|
545
|
+
);
|
|
546
|
+
}),
|
|
547
|
+
),
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// Source text has left the pipeline; sampling needs only candidate metadata and room.
|
|
552
|
+
|
|
376
553
|
/**
|
|
377
554
|
* The linter check's samples: for each project rule its tally still needs
|
|
378
555
|
* answers for, files this run judges it on, spread evenly over them in path
|
|
379
556
|
* order, skipping files the tally has and files its question would not fit.
|
|
380
557
|
*/
|
|
381
|
-
const withSamples =
|
|
382
|
-
|
|
383
|
-
for (const plan of plans) {
|
|
384
|
-
for (const { rule } of Object.values(plan.pending)) {
|
|
385
|
-
if (presetRules.has(rule) || needs.has(rule)) continue;
|
|
386
|
-
const key = yield* tallyKeyOf(config.model, rule);
|
|
387
|
-
needs.set(rule, { key, tallied: (yield* cache.tally(key))?.files ?? {} });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
for (const [rule, { tallied }] of needs) {
|
|
391
|
-
if (Object.keys(tallied).length >= SAMPLE) needs.delete(rule);
|
|
392
|
-
}
|
|
393
|
-
if (needs.size === 0) return plans;
|
|
558
|
+
const withSamples = (plans: ReadonlyArray<FilePlan>) => {
|
|
559
|
+
if (rooms.size === 0) return plans;
|
|
394
560
|
|
|
395
561
|
const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
|
|
396
562
|
const tokens = new Map<Rule, number>();
|
|
397
563
|
for (const plan of plans) {
|
|
398
|
-
|
|
564
|
+
const room = rooms.get(plan);
|
|
565
|
+
if (room === undefined) continue;
|
|
399
566
|
for (const [id, { rule }] of Object.entries(plan.pending)) {
|
|
400
567
|
const need = needs.get(rule);
|
|
401
568
|
if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
|
|
402
|
-
room ??= questionRoom(plan.file.lines);
|
|
403
569
|
const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
|
|
404
570
|
tokens.set(rule, size);
|
|
405
571
|
if (size > room) continue;
|
|
@@ -425,58 +591,48 @@ export const planAudit = (
|
|
|
425
591
|
const some = sampled.get(plan);
|
|
426
592
|
return some === undefined ? plan : { ...plan, sampled: some };
|
|
427
593
|
});
|
|
428
|
-
}
|
|
594
|
+
};
|
|
429
595
|
|
|
430
|
-
|
|
431
|
-
// but its content is: another run's rules may judge it.
|
|
432
|
-
const sorted = files
|
|
433
|
-
.filter((file) => !isEmpty(configuredRules(file)))
|
|
434
|
-
.sort((a, b) => a.path.localeCompare(b.path));
|
|
435
|
-
const unjudged = yield* Effect.forEach(
|
|
436
|
-
files.filter((file) => isEmpty(configuredRules(file))),
|
|
437
|
-
(file) => sha256(file.lines.join("\n")),
|
|
438
|
-
{ concurrency: 8 },
|
|
439
|
-
);
|
|
440
|
-
const status = yield* Status;
|
|
441
|
-
let done = 0;
|
|
442
|
-
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
443
|
-
Effect.forEach(
|
|
444
|
-
sorted,
|
|
445
|
-
(file) =>
|
|
446
|
-
planFile(file).pipe(
|
|
447
|
-
Effect.tap(() => {
|
|
448
|
-
done += 1;
|
|
449
|
-
return status.show(
|
|
450
|
-
`Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
|
|
451
|
-
);
|
|
452
|
-
}),
|
|
453
|
-
),
|
|
454
|
-
{ concurrency: 8 },
|
|
455
|
-
),
|
|
456
|
-
);
|
|
457
|
-
const limited =
|
|
458
|
-
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
459
|
-
const planned = yield* withSamples(limited);
|
|
596
|
+
const planned = withSamples(limited);
|
|
460
597
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
598
|
+
// Only added sample questions can change the base judge estimate. Recompute those files
|
|
599
|
+
// after global sampling, with the same snapshot validation as execution. Locating is unchanged.
|
|
600
|
+
yield* Effect.forEach(
|
|
601
|
+
judged.filter((plan) => !isEmpty(plan.sampled)),
|
|
602
|
+
(plan) =>
|
|
603
|
+
Effect.gen(function* () {
|
|
604
|
+
const { file } = yield* reloadFile(plan, config.includeComments === true);
|
|
605
|
+
const load = judgeLoad(
|
|
606
|
+
file.lines,
|
|
607
|
+
rulesOf(plan.pending),
|
|
608
|
+
Object.keys(plan.sampled),
|
|
609
|
+
plan.readState,
|
|
610
|
+
);
|
|
611
|
+
baseLoads.set(plan.file, { ...load, locating: baseLoads.get(plan.file)!.locating });
|
|
612
|
+
}),
|
|
613
|
+
{ concurrency: 8, discard: true },
|
|
465
614
|
);
|
|
615
|
+
const loads = [...baseLoads.values()];
|
|
466
616
|
const price = priceOf(config.model);
|
|
467
617
|
const total = (count: (plan: FilePlan) => number) =>
|
|
468
618
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
619
|
+
const cachedLocateRequests = loads.reduce((sum, load) => sum + load.locating, 0);
|
|
469
620
|
return {
|
|
470
621
|
files: planned,
|
|
622
|
+
...(config.includeComments === true ? { includeComments: true } : {}),
|
|
623
|
+
...(options.limit === 0 ? { planOnly: true } : {}),
|
|
624
|
+
...(cachedLocateRequests > 0 ? { cachedLocateRequests } : {}),
|
|
625
|
+
...(nativeReads.symbols || nativeReads.references ? { nativeReads } : {}),
|
|
471
626
|
unjudged,
|
|
472
627
|
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
473
628
|
checks: total((plan) => sizeOf(plan.prepared)),
|
|
474
629
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
475
630
|
skipped: planned.length - judged.length,
|
|
476
|
-
deferred: total((plan) => plan.deferred),
|
|
631
|
+
deferred: total((plan) => (plan.deferred ? 1 : 0)),
|
|
477
632
|
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
478
633
|
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
479
634
|
...(price === undefined ? {} : { price }),
|
|
635
|
+
...(config.provider === undefined ? {} : { provider: config.provider.model }),
|
|
480
636
|
threshold: config.threshold,
|
|
481
637
|
sufficiencyThreshold: config.sufficiencyThreshold,
|
|
482
638
|
};
|
|
@@ -484,15 +640,37 @@ export const planAudit = (
|
|
|
484
640
|
|
|
485
641
|
/**
|
|
486
642
|
* Judges what the plan left pending and locates the findings, a file at a
|
|
487
|
-
* time, telling `progress` as each file finishes.
|
|
643
|
+
* time, telling `progress` as each file finishes. A plan-only run reports
|
|
644
|
+
* cached findings without resolving native context or sending requests.
|
|
488
645
|
*/
|
|
489
646
|
export const executeAudit = (
|
|
490
647
|
plan: AuditPlan,
|
|
491
648
|
progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
|
|
492
|
-
): Effect.Effect<
|
|
649
|
+
): Effect.Effect<
|
|
650
|
+
AuditResult,
|
|
651
|
+
WalkUnavailable | JevUnavailable,
|
|
652
|
+
AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
653
|
+
> =>
|
|
493
654
|
Effect.gen(function* () {
|
|
494
655
|
const jev = yield* Jev;
|
|
495
656
|
const cache = yield* AuditCache;
|
|
657
|
+
const planOnly = plan.planOnly === true;
|
|
658
|
+
const nativeReads =
|
|
659
|
+
plan.nativeReads?.symbols || plan.nativeReads?.references ? plan.nativeReads : undefined;
|
|
660
|
+
const eligible = planOnly ? [] : plan.files.filter(needsRequests);
|
|
661
|
+
const sourceReads =
|
|
662
|
+
nativeReads === undefined || eligible.length === 0
|
|
663
|
+
? Option.none()
|
|
664
|
+
: yield* Effect.serviceOption(SourceReads);
|
|
665
|
+
if (nativeReads !== undefined && eligible.length > 0) {
|
|
666
|
+
if (Option.isNone(sourceReads)) {
|
|
667
|
+
return yield* WalkUnavailable.make({
|
|
668
|
+
message:
|
|
669
|
+
"symbols/references require the SourceReads service during execution. Run lint through adhere's npm launcher or a checkout with its development dependencies installed, or set reads: [] for code-only audits.",
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
yield* sourceReads.value.prepare(eligible.map(({ file }) => file.path));
|
|
673
|
+
}
|
|
496
674
|
// Files finish together; each tally is read, extended, and written back by one file at a time.
|
|
497
675
|
const tallying = yield* Semaphore.make(1);
|
|
498
676
|
|
|
@@ -517,25 +695,56 @@ export const executeAudit = (
|
|
|
517
695
|
),
|
|
518
696
|
);
|
|
519
697
|
|
|
520
|
-
/** Judges and locates one file
|
|
521
|
-
const judgeFile = Effect.fn("audit.judgeFile")(function* (
|
|
522
|
-
|
|
523
|
-
original,
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
kept,
|
|
527
|
-
|
|
528
|
-
deferred,
|
|
529
|
-
sampled,
|
|
530
|
-
}: FilePlan) {
|
|
531
|
-
// What a rule's appendState gets: the file as written, not as Jev reads it.
|
|
698
|
+
/** Judges and locates one file, keeping its full read state local and counting send-time splits. */
|
|
699
|
+
const judgeFile = Effect.fn("audit.judgeFile")(function* (
|
|
700
|
+
filePlan: FilePlan,
|
|
701
|
+
{ file, original }: LoadedFile,
|
|
702
|
+
sent: (requests: number) => void,
|
|
703
|
+
) {
|
|
704
|
+
const { hash, prepared, kept, pending, sampled, readState: cheapState } = filePlan;
|
|
705
|
+
// What native reads and a rule's appendState get: the file as written, not as Jev reads it.
|
|
532
706
|
const asWritten = { path: file.path, contents: original.join("\n") };
|
|
707
|
+
const checking = { ...pending, ...unlocated(prepared, kept) };
|
|
708
|
+
let readState = cheapState;
|
|
709
|
+
if (!isEmpty(checking)) {
|
|
710
|
+
if (nativeReads !== undefined && Option.isSome(sourceReads)) {
|
|
711
|
+
const native = yield* sourceReads.value
|
|
712
|
+
.read(asWritten, file.lines, nativeReads)
|
|
713
|
+
.pipe(
|
|
714
|
+
Effect.mapError((problem) =>
|
|
715
|
+
WalkUnavailable.make({ message: `${file.path}: ${problem.message}` }),
|
|
716
|
+
),
|
|
717
|
+
);
|
|
718
|
+
readState = {
|
|
719
|
+
...cheapState,
|
|
720
|
+
...Record.filter(
|
|
721
|
+
native,
|
|
722
|
+
(_, name) =>
|
|
723
|
+
(name === "symbols" && nativeReads.symbols) ||
|
|
724
|
+
(name === "references" && nativeReads.references),
|
|
725
|
+
),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
// Budget every request this file can send, never truncating native source to make it fit.
|
|
729
|
+
if (!fits(file.lines, rulesOf(checking), readState)) {
|
|
730
|
+
yield* Effect.logDebug(`${file.path}: full context too long for Jev, skipped`);
|
|
731
|
+
return fileResult("skipped", findingsOf(filePlan, kept, original));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const room = questionRoom(file.lines, readState);
|
|
735
|
+
const sentSamples = Record.filter(sampled, (_, id) => {
|
|
736
|
+
const k = pending[id];
|
|
737
|
+
return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
|
|
738
|
+
});
|
|
739
|
+
const sampleIds = Object.keys(sentSamples);
|
|
740
|
+
if (!isEmpty(pending))
|
|
741
|
+
sent(judgeRequests(file.lines, rulesOf(pending), sampleIds, readState));
|
|
533
742
|
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|
|
534
743
|
? { probabilities: {}, linter: {} }
|
|
535
744
|
: yield* jev
|
|
536
|
-
.judge(file.lines, rulesOf(pending),
|
|
745
|
+
.judge(file.lines, rulesOf(pending), sampleIds, asWritten, readState)
|
|
537
746
|
.pipe(inFile(file));
|
|
538
|
-
if (!isEmpty(linter)) yield* record(file.path,
|
|
747
|
+
if (!isEmpty(linter)) yield* record(file.path, sentSamples, linter);
|
|
539
748
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
540
749
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
541
750
|
const k = pending[id];
|
|
@@ -553,15 +762,8 @@ export const executeAudit = (
|
|
|
553
762
|
}
|
|
554
763
|
}
|
|
555
764
|
|
|
556
|
-
const flagged =
|
|
557
|
-
|
|
558
|
-
return (
|
|
559
|
-
judgment !== undefined &&
|
|
560
|
-
judgment.probability > k.threshold &&
|
|
561
|
-
inScope(judgment) &&
|
|
562
|
-
(judgment.section === undefined || judgment.sufficiency === undefined)
|
|
563
|
-
);
|
|
564
|
-
});
|
|
765
|
+
const flagged = unlocated(prepared, judged);
|
|
766
|
+
if (!isEmpty(flagged)) sent(locateRequests(file.lines, rulesOf(flagged), readState));
|
|
565
767
|
// A blocked locate question still leaves the judgments worth caching: a rerun
|
|
566
768
|
// asks only for the sections again, not for every rule.
|
|
567
769
|
const {
|
|
@@ -572,7 +774,7 @@ export const executeAudit = (
|
|
|
572
774
|
readonly blocked: Blocked | undefined;
|
|
573
775
|
} = isEmpty(flagged)
|
|
574
776
|
? { places: {}, blocked: undefined }
|
|
575
|
-
: yield* jev.locate(file.lines, rulesOf(flagged), asWritten).pipe(
|
|
777
|
+
: yield* jev.locate(file.lines, rulesOf(flagged), asWritten, readState).pipe(
|
|
576
778
|
Effect.map((places) => ({ places, blocked: undefined })),
|
|
577
779
|
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
578
780
|
Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
|
|
@@ -587,76 +789,49 @@ export const executeAudit = (
|
|
|
587
789
|
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
588
790
|
if (!cached) yield* cache.put(hash, answersOf(located));
|
|
589
791
|
|
|
590
|
-
const findings =
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
judgment.section !== undefined &&
|
|
595
|
-
judgment.probability > k.threshold &&
|
|
596
|
-
inScope(judgment)
|
|
597
|
-
? [
|
|
598
|
-
{
|
|
599
|
-
rule: id,
|
|
600
|
-
...(k.preset === undefined ? {} : { preset: k.preset }),
|
|
601
|
-
description: k.rule.description,
|
|
602
|
-
examples: examplesOf(k.rule),
|
|
603
|
-
file: file.path,
|
|
604
|
-
section: judgment.section,
|
|
605
|
-
...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
|
|
606
|
-
excerpt: excerptOf(original, judgment.section),
|
|
607
|
-
...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
|
|
608
|
-
probability: judgment.probability,
|
|
609
|
-
threshold: k.threshold,
|
|
610
|
-
level: k.rule.level ?? "error",
|
|
611
|
-
},
|
|
612
|
-
]
|
|
613
|
-
: [];
|
|
614
|
-
})
|
|
615
|
-
.sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
|
|
616
|
-
return {
|
|
617
|
-
result:
|
|
618
|
-
blocked === undefined
|
|
619
|
-
? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
|
|
620
|
-
: { status: "blocked", findings, blocked },
|
|
621
|
-
requests:
|
|
622
|
-
(isEmpty(pending)
|
|
623
|
-
? 0
|
|
624
|
-
: judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
|
|
625
|
-
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
626
|
-
};
|
|
792
|
+
const findings = findingsOf(filePlan, located, original);
|
|
793
|
+
return blocked === undefined
|
|
794
|
+
? fileResult(cached ? "cached" : "judged", findings)
|
|
795
|
+
: ({ status: "blocked", findings, blocked } satisfies FileResult);
|
|
627
796
|
});
|
|
628
797
|
|
|
629
|
-
const auditFile = Effect.fn("audit.file")(function* (
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
798
|
+
const auditFile = Effect.fn("audit.file")(function* (filePlan: FilePlan) {
|
|
799
|
+
const requesting = !planOnly && needsRequests(filePlan);
|
|
800
|
+
const loaded =
|
|
801
|
+
requesting || hasLocatedFindings(filePlan)
|
|
802
|
+
? yield* reloadFile(filePlan, plan.includeComments === true)
|
|
803
|
+
: undefined;
|
|
804
|
+
const keptFindings =
|
|
805
|
+
loaded === undefined ? [] : findingsOf(filePlan, filePlan.kept, loaded.original);
|
|
806
|
+
let sent = 0;
|
|
807
|
+
const judging = (
|
|
808
|
+
!requesting || loaded === undefined
|
|
809
|
+
? Effect.succeed(
|
|
810
|
+
fileResult(
|
|
811
|
+
filePlan.skipped
|
|
812
|
+
? "skipped"
|
|
813
|
+
: filePlan.deferred || !isEmpty(filePlan.pending)
|
|
814
|
+
? "waiting"
|
|
815
|
+
: "cached",
|
|
816
|
+
keptFindings,
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
: judgeFile(filePlan, loaded, (requests) => {
|
|
820
|
+
sent += requests;
|
|
821
|
+
})
|
|
822
|
+
).pipe(
|
|
823
|
+
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
824
|
+
Effect.catchTag("JevOverflow", () => Effect.succeed(fileResult("skipped", keptFindings))),
|
|
825
|
+
// The firewall refuses this file's code every time; the rest of the run goes on.
|
|
826
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
827
|
+
Effect.succeed({
|
|
828
|
+
status: "blocked",
|
|
829
|
+
findings: keptFindings,
|
|
830
|
+
blocked: { file: filePlan.file.path, ray },
|
|
831
|
+
} satisfies FileResult),
|
|
832
|
+
),
|
|
833
|
+
Effect.map((result) => ({ result, requests: sent })),
|
|
834
|
+
);
|
|
660
835
|
// Everything logged while judging a file, requests included, names the file.
|
|
661
836
|
// A file that sent nothing, whether cached or too long, is a trace line: a
|
|
662
837
|
// large repo's cache would otherwise bury the requests.
|
|
@@ -666,13 +841,13 @@ export const executeAudit = (
|
|
|
666
841
|
`${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
|
|
667
842
|
),
|
|
668
843
|
),
|
|
669
|
-
Effect.annotateLogs("file",
|
|
844
|
+
Effect.annotateLogs("file", filePlan.file.path),
|
|
670
845
|
);
|
|
671
846
|
// A finding an adhere-ignore comment covers is left out of the report, and counted.
|
|
672
847
|
const shown = result.findings.filter(
|
|
673
848
|
(finding) =>
|
|
674
849
|
!suppresses(
|
|
675
|
-
|
|
850
|
+
filePlan.suppressions,
|
|
676
851
|
shownId({ id: finding.rule, preset: finding.preset }),
|
|
677
852
|
finding.lines ?? finding.section,
|
|
678
853
|
),
|
|
@@ -680,7 +855,7 @@ export const executeAudit = (
|
|
|
680
855
|
if (shown.length < result.findings.length) {
|
|
681
856
|
yield* Effect.logDebug(
|
|
682
857
|
`${result.findings.length - shown.length} suppressed by adhere-ignore`,
|
|
683
|
-
).pipe(Effect.annotateLogs("file",
|
|
858
|
+
).pipe(Effect.annotateLogs("file", filePlan.file.path));
|
|
684
859
|
}
|
|
685
860
|
yield* progress({ requests, findings: shown.length });
|
|
686
861
|
return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
|