@drkmttr/adhere 0.15.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +313 -131
- 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 +62 -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 +184 -171
- package/src/services/Jev.provider.test.ts +483 -0
- package/src/services/Jev.reads.test.ts +516 -0
- package/src/services/Jev.ts +276 -120
- package/src/services/NativeLsp.ts +289 -0
- package/src/services/SourceReads.test.ts +1289 -0
- package/src/services/SourceReads.ts +460 -0
- package/src/services/SourceWalker.ts +26 -59
- package/src/workflows/audit.ts +513 -267
- 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,31 +1,47 @@
|
|
|
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
|
+
type Rules,
|
|
10
|
+
} from "#config.ts";
|
|
3
11
|
import { type Excerpt, type Range } from "#excerpt.ts";
|
|
4
|
-
import type
|
|
12
|
+
import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
5
13
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
6
14
|
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
7
15
|
import {
|
|
16
|
+
describeState,
|
|
8
17
|
fits,
|
|
9
18
|
Jev,
|
|
19
|
+
type JevBlocked,
|
|
20
|
+
type JevOverflow,
|
|
10
21
|
JevUnavailable,
|
|
11
22
|
type Judged,
|
|
12
23
|
judgeQuestion,
|
|
24
|
+
judgeBody,
|
|
13
25
|
judgeLoad,
|
|
14
26
|
judgeRequests,
|
|
15
27
|
linterQuestion,
|
|
16
28
|
type Located,
|
|
29
|
+
locateBody,
|
|
17
30
|
locateRequests,
|
|
18
31
|
matcherQuestions,
|
|
19
32
|
questionRoom,
|
|
20
33
|
tokensOf,
|
|
34
|
+
logRequestPayload,
|
|
21
35
|
} from "#services/Jev.ts";
|
|
22
|
-
import {
|
|
36
|
+
import { type SourceReadRequest, SourceReads } from "#services/SourceReads.ts";
|
|
37
|
+
import { type SourceFile, SourceWalker } from "#services/SourceWalker.ts";
|
|
23
38
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
24
39
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
25
40
|
import { priceOf } from "#pricing.ts";
|
|
26
41
|
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
42
|
+
import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
43
|
+
import { workspacePackagesIn } from "#workspaces.ts";
|
|
44
|
+
import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
|
|
29
45
|
|
|
30
46
|
export interface Finding {
|
|
31
47
|
readonly rule: RuleId;
|
|
@@ -65,7 +81,7 @@ export interface AuditResult {
|
|
|
65
81
|
readonly judged: number;
|
|
66
82
|
readonly cached: number;
|
|
67
83
|
readonly skipped: number;
|
|
68
|
-
/** Files whose
|
|
84
|
+
/** Files whose judge or cached-locate requests `--limit` left for a later run. */
|
|
69
85
|
readonly waiting: number;
|
|
70
86
|
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
71
87
|
readonly blocked: ReadonlyArray<Blocked>;
|
|
@@ -92,6 +108,7 @@ interface PreparedRule {
|
|
|
92
108
|
readonly rule: Rule;
|
|
93
109
|
readonly fingerprint: string;
|
|
94
110
|
readonly threshold: number;
|
|
111
|
+
|
|
95
112
|
/** The preset the rule came from, when it is not the project's own. */
|
|
96
113
|
readonly preset?: string;
|
|
97
114
|
}
|
|
@@ -113,23 +130,14 @@ const LAYOUT = "sections";
|
|
|
113
130
|
* matcher re-judges the rule, and a question asked differently re-judges
|
|
114
131
|
* every rule. So does an edited `appendState`, by its source; what the hook
|
|
115
132
|
* 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.
|
|
133
|
+
* not. Shared read context and related files do not invalidate judgments.
|
|
118
134
|
*/
|
|
119
|
-
const fingerprintOf = (
|
|
120
|
-
model: string,
|
|
121
|
-
rule: Rule,
|
|
122
|
-
workspacePackages: Readonly<Record<string, string>> = {},
|
|
123
|
-
) => {
|
|
135
|
+
const fingerprintOf = (model: string, rule: Rule) => {
|
|
124
136
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
125
137
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
126
138
|
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
139
|
return sha256(
|
|
132
|
-
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}
|
|
140
|
+
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
133
141
|
);
|
|
134
142
|
};
|
|
135
143
|
|
|
@@ -146,7 +154,7 @@ const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerp
|
|
|
146
154
|
const MATCHER_THRESHOLD = 0.5;
|
|
147
155
|
|
|
148
156
|
/** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
|
|
149
|
-
const inScope = (judgment: Judgment): boolean =>
|
|
157
|
+
export const inScope = (judgment: Pick<Judgment, "appliesTo" | "excludeIf">): boolean =>
|
|
150
158
|
(judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
|
|
151
159
|
!(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
|
|
152
160
|
|
|
@@ -165,19 +173,19 @@ const scopeMisses = (rule: Rule, judgment: Judgment): string =>
|
|
|
165
173
|
|
|
166
174
|
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
167
175
|
export interface FilePlan {
|
|
168
|
-
/**
|
|
169
|
-
readonly file:
|
|
170
|
-
/**
|
|
171
|
-
readonly original: ReadonlyArray<string>;
|
|
172
|
-
/** Too long for Jev: counted, not judged. */
|
|
176
|
+
/** Only the path and classification; contents are loaded in bounded passes, never kept here. */
|
|
177
|
+
readonly file: SourceFile;
|
|
178
|
+
/** Selected work is known to overflow the cheap budget; deferred files may be unbudgeted. */
|
|
173
179
|
readonly skipped: boolean;
|
|
174
180
|
readonly hash: string;
|
|
181
|
+
/** Only this file's imported workspace packages; native reads stay local to send time. */
|
|
182
|
+
readonly readState: ReadState;
|
|
175
183
|
readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
|
|
176
184
|
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
177
185
|
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
178
186
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
179
|
-
/**
|
|
180
|
-
readonly deferred:
|
|
187
|
+
/** The file's entire request workload, including cached locating, waits for a later run. */
|
|
188
|
+
readonly deferred: boolean;
|
|
181
189
|
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
182
190
|
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
183
191
|
/** What the file's `adhere-ignore` comments suppress. */
|
|
@@ -186,28 +194,39 @@ export interface FilePlan {
|
|
|
186
194
|
|
|
187
195
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
188
196
|
export interface AuditPlan {
|
|
197
|
+
readonly model: string;
|
|
189
198
|
readonly files: ReadonlyArray<FilePlan>;
|
|
199
|
+
/** The normalization used for content keys, also used when reloading a selected file. */
|
|
200
|
+
readonly includeComments?: boolean;
|
|
201
|
+
/** Explicit --limit 0 intent: no native resolution, judging, or locating, even for cached flags. */
|
|
202
|
+
readonly planOnly?: boolean;
|
|
203
|
+
/** Requested native context, not its results. Only execution discovers and uses SourceReads. */
|
|
204
|
+
readonly nativeReads?: SourceReadRequest;
|
|
190
205
|
/**
|
|
191
206
|
* The content hashes of files read that no rule in the run judges, such as
|
|
192
207
|
* tests when no rule says `tests`: a prune keeps what other runs know of them.
|
|
193
208
|
*/
|
|
194
209
|
readonly unjudged: ReadonlyArray<string>;
|
|
195
|
-
/** Rules
|
|
210
|
+
/** Rules applying to files not known to be skipped, including unbudgeted deferred files. */
|
|
196
211
|
readonly rules: number;
|
|
197
|
-
/**
|
|
212
|
+
/** File/rule pairs not known to be skipped; deferred work stays counted until budgeted. */
|
|
198
213
|
readonly checks: number;
|
|
199
214
|
/** Checks a cached judgment answers. */
|
|
200
215
|
readonly cached: number;
|
|
201
|
-
/** Files
|
|
216
|
+
/** Files whose selected work is known to exceed the cheap budget. */
|
|
202
217
|
readonly skipped: number;
|
|
203
|
-
/**
|
|
218
|
+
/** Files whose judge or cached-locate requests `--limit` leaves for a later run. */
|
|
204
219
|
readonly deferred: number;
|
|
205
|
-
/**
|
|
220
|
+
/** Judge requests estimated from code and cheap context; native reads may split or skip at send time. */
|
|
206
221
|
readonly requests: number;
|
|
207
|
-
/**
|
|
222
|
+
/** Cheap-context request estimate for locating known cached flags; omitted means zero. */
|
|
223
|
+
readonly cachedLocateRequests?: number;
|
|
224
|
+
/** Estimated judge input tokens, excluding native reads and locating findings. */
|
|
208
225
|
readonly tokens: number;
|
|
209
226
|
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
210
227
|
readonly price?: number;
|
|
228
|
+
/** The model a config's provider asks in place of Jev, which the plan names instead. */
|
|
229
|
+
readonly provider?: string;
|
|
211
230
|
/** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
|
|
212
231
|
readonly threshold: number;
|
|
213
232
|
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
@@ -224,9 +243,62 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
|
|
|
224
243
|
|
|
225
244
|
const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
|
|
226
245
|
|
|
246
|
+
/** Flagged judgments whose section or sufficiency still needs a locate request. */
|
|
247
|
+
const unlocated = (
|
|
248
|
+
prepared: Readonly<Record<RuleId, PreparedRule>>,
|
|
249
|
+
judged: Readonly<Record<RuleId, Judgment>>,
|
|
250
|
+
) =>
|
|
251
|
+
Record.filter(prepared, (k, id) => {
|
|
252
|
+
const judgment = judged[id];
|
|
253
|
+
return (
|
|
254
|
+
judgment !== undefined &&
|
|
255
|
+
judgment.probability > k.threshold &&
|
|
256
|
+
inScope(judgment) &&
|
|
257
|
+
(judgment.section === undefined || judgment.sufficiency === undefined)
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const needsRequests = (plan: FilePlan): boolean =>
|
|
262
|
+
!plan.skipped &&
|
|
263
|
+
!plan.deferred &&
|
|
264
|
+
(!isEmpty(plan.pending) || !isEmpty(unlocated(plan.prepared, plan.kept)));
|
|
265
|
+
|
|
266
|
+
/** Located findings survive skipped new work; suppression is applied by the file workflow. */
|
|
267
|
+
const findingsOf = (
|
|
268
|
+
{ file, prepared }: FilePlan,
|
|
269
|
+
judged: Readonly<Record<RuleId, Judgment>>,
|
|
270
|
+
original: ReadonlyArray<string>,
|
|
271
|
+
): ReadonlyArray<Finding> =>
|
|
272
|
+
Object.entries(judged)
|
|
273
|
+
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
274
|
+
const k = prepared[id];
|
|
275
|
+
return k !== undefined &&
|
|
276
|
+
judgment.section !== undefined &&
|
|
277
|
+
judgment.probability > k.threshold &&
|
|
278
|
+
inScope(judgment)
|
|
279
|
+
? [
|
|
280
|
+
{
|
|
281
|
+
rule: id,
|
|
282
|
+
...(k.preset === undefined ? {} : { preset: k.preset }),
|
|
283
|
+
description: k.rule.description,
|
|
284
|
+
examples: examplesOf(k.rule),
|
|
285
|
+
file: file.path,
|
|
286
|
+
section: judgment.section,
|
|
287
|
+
...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
|
|
288
|
+
excerpt: excerptOf(original, judgment.section),
|
|
289
|
+
...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
|
|
290
|
+
probability: judgment.probability,
|
|
291
|
+
threshold: k.threshold,
|
|
292
|
+
level: k.rule.level ?? "error",
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: [];
|
|
296
|
+
})
|
|
297
|
+
.sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
|
|
298
|
+
|
|
227
299
|
/** A refusal that names the file it stopped at. Files finished before it are already cached. */
|
|
228
300
|
const inFile =
|
|
229
|
-
(file:
|
|
301
|
+
(file: SourceFile) =>
|
|
230
302
|
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
231
303
|
Effect.mapError(self, (problem) =>
|
|
232
304
|
problem instanceof JevUnavailable
|
|
@@ -236,29 +308,68 @@ const inFile =
|
|
|
236
308
|
: problem,
|
|
237
309
|
);
|
|
238
310
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return planned.map((plan) => {
|
|
247
|
-
const ids = Object.keys(plan.pending);
|
|
248
|
-
const taken = new Set(ids.slice(0, left));
|
|
249
|
-
left -= taken.size;
|
|
250
|
-
return taken.size === ids.length
|
|
251
|
-
? plan
|
|
252
|
-
: {
|
|
253
|
-
...plan,
|
|
254
|
-
pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
|
|
255
|
-
deferred: ids.length - taken.size,
|
|
256
|
-
};
|
|
257
|
-
});
|
|
311
|
+
/** Preserve line numbers while blanking directives, and comments other than @adhere notes. */
|
|
312
|
+
const normalized = (original: ReadonlyArray<string>, includeComments: boolean) => {
|
|
313
|
+
const { lines, suppressions } = suppressionsOf(original);
|
|
314
|
+
return {
|
|
315
|
+
lines: includeComments ? lines : withoutComments(lines, isNote),
|
|
316
|
+
suppressions,
|
|
317
|
+
};
|
|
258
318
|
};
|
|
259
319
|
|
|
320
|
+
interface LoadedFile {
|
|
321
|
+
readonly file: ScannedFile;
|
|
322
|
+
readonly original: ReadonlyArray<string>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Ignore directives are absent from the code key, but their parsed policy still belongs to the plan. */
|
|
326
|
+
const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
|
|
327
|
+
if (left === right) return true;
|
|
328
|
+
const sameRules = (a: ReadonlySet<string>, b: ReadonlySet<string>) =>
|
|
329
|
+
a.size === b.size && [...a].every((rule) => b.has(rule));
|
|
330
|
+
return (
|
|
331
|
+
sameRules(left.file, right.file) &&
|
|
332
|
+
left.statements.length === right.statements.length &&
|
|
333
|
+
left.statements.every((statement, index) => {
|
|
334
|
+
const other = right.statements[index]!;
|
|
335
|
+
return (
|
|
336
|
+
statement.first === other.first &&
|
|
337
|
+
statement.last === other.last &&
|
|
338
|
+
sameRules(statement.rules, other.rules)
|
|
339
|
+
);
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Validate code and ignore policy before sending a request or showing a cached excerpt. */
|
|
345
|
+
const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
|
|
346
|
+
const original = yield* (yield* SourceWalker).read(plan.file);
|
|
347
|
+
const { lines, suppressions } = normalized(original.lines, includeComments);
|
|
348
|
+
if (
|
|
349
|
+
(yield* sha256(lines.join("\n"))) !== plan.hash ||
|
|
350
|
+
!sameSuppressions(plan.suppressions, suppressions)
|
|
351
|
+
) {
|
|
352
|
+
return yield* WalkUnavailable.make({
|
|
353
|
+
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.`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/** A cached finding needs the source again even when no request will be sent. */
|
|
360
|
+
const hasLocatedFindings = (plan: FilePlan): boolean =>
|
|
361
|
+
Object.entries(plan.kept).some(([id, judgment]) => {
|
|
362
|
+
const prepared = plan.prepared[id];
|
|
363
|
+
return (
|
|
364
|
+
prepared !== undefined &&
|
|
365
|
+
judgment.section !== undefined &&
|
|
366
|
+
judgment.probability > prepared.threshold &&
|
|
367
|
+
inScope(judgment)
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
260
371
|
export interface PlanOptions {
|
|
261
|
-
/**
|
|
372
|
+
/** Select at most this many files needing judge or cached-locate requests; zero plans only. */
|
|
262
373
|
readonly limit?: number | undefined;
|
|
263
374
|
}
|
|
264
375
|
|
|
@@ -272,72 +383,71 @@ export const planAudit = (
|
|
|
272
383
|
Effect.gen(function* () {
|
|
273
384
|
const config = yield* AdhereConfig;
|
|
274
385
|
const cache = yield* AuditCache;
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
386
|
+
const workspacePackages = config.reads.includes("workspacePackages")
|
|
387
|
+
? (config.workspacePackages ?? {})
|
|
388
|
+
: undefined;
|
|
389
|
+
const nativeReads: SourceReadRequest = {
|
|
390
|
+
symbols: config.reads.includes("symbols"),
|
|
391
|
+
references: config.reads.includes("references"),
|
|
392
|
+
includeComments: config.includeComments === true,
|
|
393
|
+
maxSymbolLength: config.maxSymbolLength,
|
|
394
|
+
maxSymbolDocumentationLength: config.maxSymbolDocumentationLength,
|
|
395
|
+
};
|
|
396
|
+
const walker = yield* SourceWalker;
|
|
397
|
+
const files = yield* walker.files;
|
|
398
|
+
|
|
399
|
+
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
400
|
+
const presetOfRule = new Map(
|
|
401
|
+
(config.scopedRules ?? []).flatMap((entry) =>
|
|
402
|
+
entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
|
|
403
|
+
),
|
|
404
|
+
);
|
|
291
405
|
|
|
292
406
|
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
293
407
|
// judge a test, and none that an adhere-ignore-file comment names.
|
|
294
|
-
const configuredRules = (file:
|
|
408
|
+
const configuredRules = (file: SourceFile, suppressions: Suppressions) =>
|
|
295
409
|
Record.filter(
|
|
296
410
|
config.scopedRules === undefined
|
|
297
411
|
? config.rules
|
|
298
412
|
: applicableRules(file.path, config.scopedRules),
|
|
299
413
|
(rule, id) =>
|
|
300
414
|
judgesFile(rule, file.test === true) &&
|
|
301
|
-
!
|
|
415
|
+
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })),
|
|
302
416
|
);
|
|
303
417
|
|
|
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) => {
|
|
418
|
+
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
419
|
+
const preparedRules = new Map<Rule, PreparedRule>(
|
|
420
|
+
yield* Effect.forEach(
|
|
421
|
+
new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
|
|
422
|
+
(rule) =>
|
|
423
|
+
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
328
424
|
const preset = presetOfRule.get(rule);
|
|
329
425
|
return [
|
|
330
|
-
|
|
331
|
-
{
|
|
426
|
+
rule,
|
|
427
|
+
Object.freeze({
|
|
332
428
|
rule,
|
|
333
429
|
fingerprint,
|
|
334
430
|
threshold: rule.threshold ?? config.threshold,
|
|
335
431
|
...(preset === undefined ? {} : { preset }),
|
|
336
|
-
},
|
|
432
|
+
}),
|
|
337
433
|
] as const;
|
|
338
434
|
}),
|
|
339
|
-
)
|
|
340
|
-
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
|
|
439
|
+
const original = yield* walker.read(file);
|
|
440
|
+
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
441
|
+
const rules = configuredRules(file, suppressions);
|
|
442
|
+
const code = lines.join("\n");
|
|
443
|
+
const readState: ReadState =
|
|
444
|
+
workspacePackages === undefined
|
|
445
|
+
? {}
|
|
446
|
+
: { workspacePackages: workspacePackagesIn(code, workspacePackages) };
|
|
447
|
+
// Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
|
|
448
|
+
const hash = yield* sha256(code);
|
|
449
|
+
const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
|
|
450
|
+
const answers = isEmpty(prepared) ? {} : yield* cache.get(hash, file.path);
|
|
341
451
|
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
342
452
|
const answer = answers[fingerprint];
|
|
343
453
|
return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
|
|
@@ -347,25 +457,22 @@ export const planAudit = (
|
|
|
347
457
|
`plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
|
|
348
458
|
);
|
|
349
459
|
return {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
460
|
+
lines,
|
|
461
|
+
plan: {
|
|
462
|
+
file,
|
|
463
|
+
skipped: false,
|
|
464
|
+
hash,
|
|
465
|
+
readState,
|
|
466
|
+
prepared,
|
|
467
|
+
kept,
|
|
468
|
+
pending,
|
|
469
|
+
deferred: false,
|
|
470
|
+
sampled: {},
|
|
471
|
+
suppressions,
|
|
472
|
+
} satisfies FilePlan,
|
|
473
|
+
};
|
|
361
474
|
});
|
|
362
475
|
|
|
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
476
|
// Preset rules are not the project's to change, so the linter check leaves them out.
|
|
370
477
|
const presetRules = new Set(
|
|
371
478
|
(config.scopedRules ?? []).flatMap((entry) =>
|
|
@@ -373,33 +480,109 @@ export const planAudit = (
|
|
|
373
480
|
),
|
|
374
481
|
);
|
|
375
482
|
|
|
483
|
+
// Ordered prefetch keeps only a bounded number of normalized files alive. Select whole
|
|
484
|
+
// request-bearing files before budgeting them; cheap overflow leaves the slot for the next path.
|
|
485
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
486
|
+
const limited: Array<FilePlan> = [];
|
|
487
|
+
const unjudged: Array<string> = [];
|
|
488
|
+
const rooms = new Map<FilePlan, number>();
|
|
489
|
+
// Undefined marks a full tally, avoiding repeated lookups without retaining its file map.
|
|
490
|
+
const needs = new Map<
|
|
491
|
+
Rule,
|
|
492
|
+
{ readonly key: string; readonly tallied: Tally["files"] } | undefined
|
|
493
|
+
>();
|
|
494
|
+
const baseLoads = new Map<
|
|
495
|
+
SourceFile,
|
|
496
|
+
{ readonly requests: number; readonly tokens: number; readonly locating: number }
|
|
497
|
+
>();
|
|
498
|
+
const status = yield* Status;
|
|
499
|
+
let left = options.limit ?? Infinity;
|
|
500
|
+
let done = 0;
|
|
501
|
+
yield* clearingStatus(
|
|
502
|
+
Stream.fromIterable(sorted).pipe(
|
|
503
|
+
Stream.mapEffect(planFile, { concurrency: 8 }),
|
|
504
|
+
Stream.runForEach(({ plan, lines }) =>
|
|
505
|
+
Effect.gen(function* () {
|
|
506
|
+
if (isEmpty(plan.prepared)) {
|
|
507
|
+
unjudged.push(plan.hash);
|
|
508
|
+
} else {
|
|
509
|
+
const pending = plan.pending;
|
|
510
|
+
const flagged = unlocated(plan.prepared, plan.kept);
|
|
511
|
+
const checking = { ...pending, ...flagged };
|
|
512
|
+
if (isEmpty(checking)) {
|
|
513
|
+
limited.push(plan);
|
|
514
|
+
} else if (left <= 0) {
|
|
515
|
+
limited.push({ ...plan, pending: {}, deferred: true });
|
|
516
|
+
} else if (!fits(lines, rulesOf(checking), plan.readState)) {
|
|
517
|
+
yield* logRequestPayload(
|
|
518
|
+
`${plan.file.path}: too long preflight payload (planning)`,
|
|
519
|
+
() => ({
|
|
520
|
+
judge: judgeBody(config.model, lines, rulesOf(checking), [], plan.readState),
|
|
521
|
+
locate: locateBody(config.model, lines, rulesOf(checking), plan.readState),
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
limited.push({ ...plan, skipped: true, pending: {} });
|
|
525
|
+
} else {
|
|
526
|
+
left -= 1;
|
|
527
|
+
const selected = plan;
|
|
528
|
+
limited.push(selected);
|
|
529
|
+
if (!isEmpty(checking)) {
|
|
530
|
+
// Exact base estimates while the code is already loaded; retain only scalars.
|
|
531
|
+
const load = isEmpty(pending)
|
|
532
|
+
? { requests: 0, tokens: 0 }
|
|
533
|
+
: judgeLoad(lines, rulesOf(pending), [], plan.readState);
|
|
534
|
+
baseLoads.set(selected.file, {
|
|
535
|
+
...load,
|
|
536
|
+
locating: isEmpty(flagged)
|
|
537
|
+
? 0
|
|
538
|
+
: locateRequests(lines, rulesOf(flagged), plan.readState),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
let candidate = false;
|
|
542
|
+
for (const { rule } of Object.values(pending)) {
|
|
543
|
+
if (presetRules.has(rule)) continue;
|
|
544
|
+
if (!needs.has(rule)) {
|
|
545
|
+
const key = yield* tallyKeyOf(config.model, rule);
|
|
546
|
+
const tallied = (yield* cache.tally(key))?.files ?? {};
|
|
547
|
+
needs.set(
|
|
548
|
+
rule,
|
|
549
|
+
Object.keys(tallied).length >= SAMPLE ? undefined : { key, tallied },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const need = needs.get(rule);
|
|
553
|
+
if (need !== undefined && need.tallied[selected.file.path] === undefined)
|
|
554
|
+
candidate = true;
|
|
555
|
+
}
|
|
556
|
+
if (candidate) rooms.set(selected, questionRoom(lines, plan.readState));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
done += 1;
|
|
560
|
+
yield* status.show(
|
|
561
|
+
`Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
|
|
562
|
+
);
|
|
563
|
+
}),
|
|
564
|
+
),
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// Source text has left the pipeline; sampling needs only candidate metadata and room.
|
|
569
|
+
|
|
376
570
|
/**
|
|
377
571
|
* The linter check's samples: for each project rule its tally still needs
|
|
378
572
|
* answers for, files this run judges it on, spread evenly over them in path
|
|
379
573
|
* order, skipping files the tally has and files its question would not fit.
|
|
380
574
|
*/
|
|
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;
|
|
575
|
+
const withSamples = (plans: ReadonlyArray<FilePlan>) => {
|
|
576
|
+
if (rooms.size === 0) return plans;
|
|
394
577
|
|
|
395
578
|
const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
|
|
396
579
|
const tokens = new Map<Rule, number>();
|
|
397
580
|
for (const plan of plans) {
|
|
398
|
-
|
|
581
|
+
const room = rooms.get(plan);
|
|
582
|
+
if (room === undefined) continue;
|
|
399
583
|
for (const [id, { rule }] of Object.entries(plan.pending)) {
|
|
400
584
|
const need = needs.get(rule);
|
|
401
585
|
if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
|
|
402
|
-
room ??= questionRoom(plan.file.lines);
|
|
403
586
|
const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
|
|
404
587
|
tokens.set(rule, size);
|
|
405
588
|
if (size > room) continue;
|
|
@@ -425,58 +608,49 @@ export const planAudit = (
|
|
|
425
608
|
const some = sampled.get(plan);
|
|
426
609
|
return some === undefined ? plan : { ...plan, sampled: some };
|
|
427
610
|
});
|
|
428
|
-
}
|
|
611
|
+
};
|
|
429
612
|
|
|
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);
|
|
613
|
+
const planned = withSamples(limited);
|
|
460
614
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
615
|
+
// Only added sample questions can change the base judge estimate. Recompute those files
|
|
616
|
+
// after global sampling, with the same snapshot validation as execution. Locating is unchanged.
|
|
617
|
+
yield* Effect.forEach(
|
|
618
|
+
judged.filter((plan) => !isEmpty(plan.sampled)),
|
|
619
|
+
(plan) =>
|
|
620
|
+
Effect.gen(function* () {
|
|
621
|
+
const { file } = yield* reloadFile(plan, config.includeComments === true);
|
|
622
|
+
const load = judgeLoad(
|
|
623
|
+
file.lines,
|
|
624
|
+
rulesOf(plan.pending),
|
|
625
|
+
Object.keys(plan.sampled),
|
|
626
|
+
plan.readState,
|
|
627
|
+
);
|
|
628
|
+
baseLoads.set(plan.file, { ...load, locating: baseLoads.get(plan.file)!.locating });
|
|
629
|
+
}),
|
|
630
|
+
{ concurrency: 8, discard: true },
|
|
465
631
|
);
|
|
632
|
+
const loads = [...baseLoads.values()];
|
|
466
633
|
const price = priceOf(config.model);
|
|
467
634
|
const total = (count: (plan: FilePlan) => number) =>
|
|
468
635
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
636
|
+
const cachedLocateRequests = loads.reduce((sum, load) => sum + load.locating, 0);
|
|
469
637
|
return {
|
|
638
|
+
model: config.model,
|
|
470
639
|
files: planned,
|
|
640
|
+
...(config.includeComments === true ? { includeComments: true } : {}),
|
|
641
|
+
...(options.limit === 0 ? { planOnly: true } : {}),
|
|
642
|
+
...(cachedLocateRequests > 0 ? { cachedLocateRequests } : {}),
|
|
643
|
+
...(nativeReads.symbols || nativeReads.references ? { nativeReads } : {}),
|
|
471
644
|
unjudged,
|
|
472
645
|
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
473
646
|
checks: total((plan) => sizeOf(plan.prepared)),
|
|
474
647
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
475
648
|
skipped: planned.length - judged.length,
|
|
476
|
-
deferred: total((plan) => plan.deferred),
|
|
649
|
+
deferred: total((plan) => (plan.deferred ? 1 : 0)),
|
|
477
650
|
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
478
651
|
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
479
652
|
...(price === undefined ? {} : { price }),
|
|
653
|
+
...(config.provider === undefined ? {} : { provider: config.provider.model }),
|
|
480
654
|
threshold: config.threshold,
|
|
481
655
|
sufficiencyThreshold: config.sufficiencyThreshold,
|
|
482
656
|
};
|
|
@@ -484,15 +658,37 @@ export const planAudit = (
|
|
|
484
658
|
|
|
485
659
|
/**
|
|
486
660
|
* Judges what the plan left pending and locates the findings, a file at a
|
|
487
|
-
* time, telling `progress` as each file finishes.
|
|
661
|
+
* time, telling `progress` as each file finishes. A plan-only run reports
|
|
662
|
+
* cached findings without resolving native context or sending requests.
|
|
488
663
|
*/
|
|
489
664
|
export const executeAudit = (
|
|
490
665
|
plan: AuditPlan,
|
|
491
666
|
progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
|
|
492
|
-
): Effect.Effect<
|
|
667
|
+
): Effect.Effect<
|
|
668
|
+
AuditResult,
|
|
669
|
+
WalkUnavailable | JevUnavailable,
|
|
670
|
+
AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
671
|
+
> =>
|
|
493
672
|
Effect.gen(function* () {
|
|
494
673
|
const jev = yield* Jev;
|
|
495
674
|
const cache = yield* AuditCache;
|
|
675
|
+
const planOnly = plan.planOnly === true;
|
|
676
|
+
const nativeReads =
|
|
677
|
+
plan.nativeReads?.symbols || plan.nativeReads?.references ? plan.nativeReads : undefined;
|
|
678
|
+
const eligible = planOnly ? [] : plan.files.filter(needsRequests);
|
|
679
|
+
const sourceReads =
|
|
680
|
+
nativeReads === undefined || eligible.length === 0
|
|
681
|
+
? Option.none()
|
|
682
|
+
: yield* Effect.serviceOption(SourceReads);
|
|
683
|
+
if (nativeReads !== undefined && eligible.length > 0) {
|
|
684
|
+
if (Option.isNone(sourceReads)) {
|
|
685
|
+
return yield* WalkUnavailable.make({
|
|
686
|
+
message:
|
|
687
|
+
"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.",
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
yield* sourceReads.value.prepare(eligible.map(({ file }) => file.path));
|
|
691
|
+
}
|
|
496
692
|
// Files finish together; each tally is read, extended, and written back by one file at a time.
|
|
497
693
|
const tallying = yield* Semaphore.make(1);
|
|
498
694
|
|
|
@@ -517,25 +713,106 @@ export const executeAudit = (
|
|
|
517
713
|
),
|
|
518
714
|
);
|
|
519
715
|
|
|
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.
|
|
716
|
+
/** Judges and locates one file, keeping its full read state local and counting send-time splits. */
|
|
717
|
+
const judgeFile = Effect.fn("audit.judgeFile")(function* (
|
|
718
|
+
filePlan: FilePlan,
|
|
719
|
+
{ file, original }: LoadedFile,
|
|
720
|
+
sent: (requests: number) => void,
|
|
721
|
+
) {
|
|
722
|
+
const { hash, prepared, kept, pending, sampled, readState: cheapState } = filePlan;
|
|
723
|
+
// What native reads and a rule's appendState get: the file as written, not as Jev reads it.
|
|
532
724
|
const asWritten = { path: file.path, contents: original.join("\n") };
|
|
725
|
+
const checking = { ...pending, ...unlocated(prepared, kept) };
|
|
726
|
+
let readState = cheapState;
|
|
727
|
+
if (!isEmpty(checking)) {
|
|
728
|
+
if (nativeReads !== undefined && Option.isSome(sourceReads)) {
|
|
729
|
+
const native = yield* sourceReads.value
|
|
730
|
+
.read(asWritten, file.lines, nativeReads)
|
|
731
|
+
.pipe(
|
|
732
|
+
Effect.mapError((problem) =>
|
|
733
|
+
WalkUnavailable.make({ message: `${file.path}: ${problem.message}` }),
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
readState = {
|
|
737
|
+
...cheapState,
|
|
738
|
+
...Record.filter(
|
|
739
|
+
native,
|
|
740
|
+
(_, name) =>
|
|
741
|
+
(name === "symbols" && nativeReads.symbols) ||
|
|
742
|
+
(name === "references" && nativeReads.references),
|
|
743
|
+
),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
let referencesOmitted = false;
|
|
748
|
+
const hasHooks = Object.values(checking).some(({ rule }) => rule.appendState !== undefined);
|
|
749
|
+
let referencesForHooks: unknown;
|
|
750
|
+
const omitReferences = (reason: string) =>
|
|
751
|
+
Effect.gen(function* () {
|
|
752
|
+
referencesForHooks = hasHooks ? readState.references : undefined;
|
|
753
|
+
readState = Record.filter(readState, (_, name) => name !== "references");
|
|
754
|
+
referencesOmitted = true;
|
|
755
|
+
yield* Effect.logDebug(`${file.path}: ${reason}; attempting without references`);
|
|
756
|
+
});
|
|
757
|
+
const rulesFor = (entries: FilePlan["prepared"]): Rules => {
|
|
758
|
+
const rules = rulesOf(entries);
|
|
759
|
+
if (!referencesOmitted) return rules;
|
|
760
|
+
return Record.map(rules, (rule) => {
|
|
761
|
+
const appendState = rule.appendState;
|
|
762
|
+
return appendState === undefined
|
|
763
|
+
? rule
|
|
764
|
+
: {
|
|
765
|
+
...rule,
|
|
766
|
+
appendState: async (state, file, bun) => {
|
|
767
|
+
const input =
|
|
768
|
+
referencesForHooks === undefined
|
|
769
|
+
? state
|
|
770
|
+
: describeState({ ...state, references: referencesForHooks });
|
|
771
|
+
return Record.filter(
|
|
772
|
+
await appendState(input, file, bun),
|
|
773
|
+
(_, name) => name !== "references",
|
|
774
|
+
);
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
};
|
|
779
|
+
const retryWithoutReferences = <A>(
|
|
780
|
+
work: () => Effect.Effect<A, JevUnavailable | JevOverflow | JevBlocked>,
|
|
781
|
+
) =>
|
|
782
|
+
Effect.suspend(work).pipe(
|
|
783
|
+
Effect.catchTag("JevOverflow", (problem) =>
|
|
784
|
+
referencesOmitted || (!("references" in readState) && !hasHooks)
|
|
785
|
+
? Effect.fail(problem)
|
|
786
|
+
: omitReferences("provider context limit").pipe(Effect.andThen(Effect.suspend(work))),
|
|
787
|
+
),
|
|
788
|
+
);
|
|
789
|
+
if (!isEmpty(checking) && !fits(file.lines, rulesOf(checking), readState)) {
|
|
790
|
+
yield* logRequestPayload(`${file.path}: too long preflight payload (full context)`, () => ({
|
|
791
|
+
judge: judgeBody(plan.model, file.lines, rulesOf(checking), [], readState),
|
|
792
|
+
locate: locateBody(plan.model, file.lines, rulesOf(checking), readState),
|
|
793
|
+
}));
|
|
794
|
+
if ("references" in readState) {
|
|
795
|
+
// Let the provider decide whether the reduced payload fits; do not veto the fallback.
|
|
796
|
+
yield* omitReferences("full context too long for Jev (preflight limit)");
|
|
797
|
+
} else {
|
|
798
|
+
yield* Effect.logDebug(`${file.path}: full context too long for Jev, skipped`);
|
|
799
|
+
return fileResult("skipped", findingsOf(filePlan, kept, original));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const room = questionRoom(file.lines, readState);
|
|
803
|
+
const sentSamples = Record.filter(sampled, (_, id) => {
|
|
804
|
+
const k = pending[id];
|
|
805
|
+
return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
|
|
806
|
+
});
|
|
807
|
+
const sampleIds = Object.keys(sentSamples);
|
|
533
808
|
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|
|
534
809
|
? { probabilities: {}, linter: {} }
|
|
535
|
-
: yield*
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
810
|
+
: yield* retryWithoutReferences(() => {
|
|
811
|
+
const rules = rulesFor(pending);
|
|
812
|
+
sent(judgeRequests(file.lines, rules, sampleIds, readState));
|
|
813
|
+
return jev.judge(file.lines, rules, sampleIds, asWritten, readState).pipe(inFile(file));
|
|
814
|
+
});
|
|
815
|
+
if (!isEmpty(linter)) yield* record(file.path, sentSamples, linter);
|
|
539
816
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
540
817
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
541
818
|
const k = pending[id];
|
|
@@ -553,15 +830,7 @@ export const executeAudit = (
|
|
|
553
830
|
}
|
|
554
831
|
}
|
|
555
832
|
|
|
556
|
-
const flagged =
|
|
557
|
-
const judgment = judged[id];
|
|
558
|
-
return (
|
|
559
|
-
judgment !== undefined &&
|
|
560
|
-
judgment.probability > k.threshold &&
|
|
561
|
-
inScope(judgment) &&
|
|
562
|
-
(judgment.section === undefined || judgment.sufficiency === undefined)
|
|
563
|
-
);
|
|
564
|
-
});
|
|
833
|
+
const flagged = unlocated(prepared, judged);
|
|
565
834
|
// A blocked locate question still leaves the judgments worth caching: a rerun
|
|
566
835
|
// asks only for the sections again, not for every rule.
|
|
567
836
|
const {
|
|
@@ -572,7 +841,11 @@ export const executeAudit = (
|
|
|
572
841
|
readonly blocked: Blocked | undefined;
|
|
573
842
|
} = isEmpty(flagged)
|
|
574
843
|
? { places: {}, blocked: undefined }
|
|
575
|
-
: yield*
|
|
844
|
+
: yield* retryWithoutReferences(() => {
|
|
845
|
+
const rules = rulesFor(flagged);
|
|
846
|
+
sent(locateRequests(file.lines, rules, readState));
|
|
847
|
+
return jev.locate(file.lines, rules, asWritten, readState);
|
|
848
|
+
}).pipe(
|
|
576
849
|
Effect.map((places) => ({ places, blocked: undefined })),
|
|
577
850
|
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
578
851
|
Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
|
|
@@ -587,76 +860,49 @@ export const executeAudit = (
|
|
|
587
860
|
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
588
861
|
if (!cached) yield* cache.put(hash, answersOf(located));
|
|
589
862
|
|
|
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
|
-
};
|
|
863
|
+
const findings = findingsOf(filePlan, located, original);
|
|
864
|
+
return blocked === undefined
|
|
865
|
+
? fileResult(cached ? "cached" : "judged", findings)
|
|
866
|
+
: ({ status: "blocked", findings, blocked } satisfies FileResult);
|
|
627
867
|
});
|
|
628
868
|
|
|
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
|
-
|
|
869
|
+
const auditFile = Effect.fn("audit.file")(function* (filePlan: FilePlan) {
|
|
870
|
+
const requesting = !planOnly && needsRequests(filePlan);
|
|
871
|
+
const loaded =
|
|
872
|
+
requesting || hasLocatedFindings(filePlan)
|
|
873
|
+
? yield* reloadFile(filePlan, plan.includeComments === true)
|
|
874
|
+
: undefined;
|
|
875
|
+
const keptFindings =
|
|
876
|
+
loaded === undefined ? [] : findingsOf(filePlan, filePlan.kept, loaded.original);
|
|
877
|
+
let sent = 0;
|
|
878
|
+
const judging = (
|
|
879
|
+
!requesting || loaded === undefined
|
|
880
|
+
? Effect.succeed(
|
|
881
|
+
fileResult(
|
|
882
|
+
filePlan.skipped
|
|
883
|
+
? "skipped"
|
|
884
|
+
: filePlan.deferred || !isEmpty(filePlan.pending)
|
|
885
|
+
? "waiting"
|
|
886
|
+
: "cached",
|
|
887
|
+
keptFindings,
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
: judgeFile(filePlan, loaded, (requests) => {
|
|
891
|
+
sent += requests;
|
|
892
|
+
})
|
|
893
|
+
).pipe(
|
|
894
|
+
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
895
|
+
Effect.catchTag("JevOverflow", () => Effect.succeed(fileResult("skipped", keptFindings))),
|
|
896
|
+
// The firewall refuses this file's code every time; the rest of the run goes on.
|
|
897
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
898
|
+
Effect.succeed({
|
|
899
|
+
status: "blocked",
|
|
900
|
+
findings: keptFindings,
|
|
901
|
+
blocked: { file: filePlan.file.path, ray },
|
|
902
|
+
} satisfies FileResult),
|
|
903
|
+
),
|
|
904
|
+
Effect.map((result) => ({ result, requests: sent })),
|
|
905
|
+
);
|
|
660
906
|
// Everything logged while judging a file, requests included, names the file.
|
|
661
907
|
// A file that sent nothing, whether cached or too long, is a trace line: a
|
|
662
908
|
// large repo's cache would otherwise bury the requests.
|
|
@@ -666,13 +912,13 @@ export const executeAudit = (
|
|
|
666
912
|
`${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
|
|
667
913
|
),
|
|
668
914
|
),
|
|
669
|
-
Effect.annotateLogs("file",
|
|
915
|
+
Effect.annotateLogs("file", filePlan.file.path),
|
|
670
916
|
);
|
|
671
917
|
// A finding an adhere-ignore comment covers is left out of the report, and counted.
|
|
672
918
|
const shown = result.findings.filter(
|
|
673
919
|
(finding) =>
|
|
674
920
|
!suppresses(
|
|
675
|
-
|
|
921
|
+
filePlan.suppressions,
|
|
676
922
|
shownId({ id: finding.rule, preset: finding.preset }),
|
|
677
923
|
finding.lines ?? finding.section,
|
|
678
924
|
),
|
|
@@ -680,7 +926,7 @@ export const executeAudit = (
|
|
|
680
926
|
if (shown.length < result.findings.length) {
|
|
681
927
|
yield* Effect.logDebug(
|
|
682
928
|
`${result.findings.length - shown.length} suppressed by adhere-ignore`,
|
|
683
|
-
).pipe(Effect.annotateLogs("file",
|
|
929
|
+
).pipe(Effect.annotateLogs("file", filePlan.file.path));
|
|
684
930
|
}
|
|
685
931
|
yield* progress({ requests, findings: shown.length });
|
|
686
932
|
return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
|