@drkmttr/adhere 0.7.1 → 0.8.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/LICENSE +21 -0
- package/README.md +156 -32
- package/package.json +13 -12
- package/presets/effect/basics/external-calls-are-resilient.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +39 -15
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +1 -2
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +13 -17
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +92 -18
- package/src/services/Jev.ts +77 -7
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +210 -30
- package/src/workflows/format.ts +77 -21
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- package/presets/effect/testing/test-random.md +0 -22
|
@@ -79,9 +79,16 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
|
|
|
79
79
|
/** One root's paths, filtered to what the audit reads. */
|
|
80
80
|
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
81
81
|
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
82
|
-
Effect.
|
|
83
|
-
scannable(root, paths, self, keep)
|
|
84
|
-
|
|
82
|
+
Effect.flatMap(fs.readDirectory(root, { recursive: true }), (paths) => {
|
|
83
|
+
const files = scannable(root, paths, self, keep);
|
|
84
|
+
// The paths listed against the files kept: node_modules shows as the difference.
|
|
85
|
+
return Effect.as(
|
|
86
|
+
Effect.logDebug(
|
|
87
|
+
`listed ${root}: ${paths.length} paths, ${files.length} source files to read`,
|
|
88
|
+
),
|
|
89
|
+
files,
|
|
90
|
+
);
|
|
91
|
+
}),
|
|
85
92
|
);
|
|
86
93
|
|
|
87
94
|
/** One scannable path, read into lines. */
|
|
@@ -89,7 +96,7 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
|
|
|
89
96
|
Effect.map(fs.readFileString(path), (content): ScannedFile => ({
|
|
90
97
|
path,
|
|
91
98
|
lines: content.split("\n"),
|
|
92
|
-
})),
|
|
99
|
+
})).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
|
|
93
100
|
);
|
|
94
101
|
|
|
95
102
|
/** Read a batch of paths; the inner loop hoisted out of `forEach`. */
|
package/src/wording.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type Example, examplesOf, type Rule } from "#config.ts";
|
|
2
|
+
import type { RuleEntry } from "#rules.ts";
|
|
3
|
+
|
|
4
|
+
type Word = Example["word"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A way a rule's wording differs from the README's rule writing tips, which
|
|
8
|
+
* the eval measured: the rule shows code of both kinds, and its description
|
|
9
|
+
* says the words its code is under.
|
|
10
|
+
*/
|
|
11
|
+
export type Straying =
|
|
12
|
+
/** The rule has code under `word`, and its description does not say it. */
|
|
13
|
+
| { readonly _tag: "Unsaid"; readonly word: Word }
|
|
14
|
+
/** The rule has code to write, and no example under `word` of code that breaks it. */
|
|
15
|
+
| { readonly _tag: "Unshown"; readonly word: Word }
|
|
16
|
+
/** The description says `word`, which belongs to the other kind of rule. */
|
|
17
|
+
| { readonly _tag: "Mixed"; readonly word: Word };
|
|
18
|
+
|
|
19
|
+
/** A rule whose wording differs from the tips, with each way it does. */
|
|
20
|
+
export interface Stray {
|
|
21
|
+
readonly entry: RuleEntry;
|
|
22
|
+
readonly straying: ReadonlyArray<Straying>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TIPS_URL = "https://github.com/darkmatter/adhere#rule-writing-tips";
|
|
26
|
+
|
|
27
|
+
/** Whether a description says a word, whole and in any case: "Must" does, "mustard" does not. */
|
|
28
|
+
const says = (description: string, word: Word): boolean =>
|
|
29
|
+
new RegExp(`\\b${word.replace(" ", "\\s+")}\\b`, "i").test(description);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How one rule strays from the tips. A rule with only code that breaks it
|
|
33
|
+
* lacks no example: the README keeps that for a rule with no single correct
|
|
34
|
+
* form to show.
|
|
35
|
+
*/
|
|
36
|
+
export const strayingOf = (rule: Rule): ReadonlyArray<Straying> => {
|
|
37
|
+
const { good, bad } = examplesOf(rule);
|
|
38
|
+
const guideline = good?.word === "should" || bad?.word === "should not";
|
|
39
|
+
const shown = [good, bad].flatMap((example) => (example === undefined ? [] : [example.word]));
|
|
40
|
+
const unshown: ReadonlyArray<Word> =
|
|
41
|
+
good !== undefined && bad === undefined ? [guideline ? "should not" : "never"] : [];
|
|
42
|
+
// "should" also finds "should not", so a requirement is checked for the one word.
|
|
43
|
+
const others: ReadonlyArray<Word> = guideline ? ["must", "never"] : ["should"];
|
|
44
|
+
return [
|
|
45
|
+
...shown
|
|
46
|
+
.filter((word) => !says(rule.description, word))
|
|
47
|
+
.map((word): Straying => ({ _tag: "Unsaid", word })),
|
|
48
|
+
...unshown.map((word): Straying => ({ _tag: "Unshown", word })),
|
|
49
|
+
...others
|
|
50
|
+
.filter((word) => says(rule.description, word))
|
|
51
|
+
.map((word): Straying => ({ _tag: "Mixed", word })),
|
|
52
|
+
];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The rules that stray, in the order given. */
|
|
56
|
+
export const straysAmong = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<Stray> =>
|
|
57
|
+
entries.flatMap((entry) => {
|
|
58
|
+
const straying = strayingOf(entry.rule);
|
|
59
|
+
return straying.length === 0 ? [] : [{ entry, straying }];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** One way a rule strays, as a sentence. */
|
|
63
|
+
const sentenceOf = (straying: Straying): string => {
|
|
64
|
+
switch (straying._tag) {
|
|
65
|
+
case "Unsaid":
|
|
66
|
+
return `The description does not say "${straying.word}", though the rule has a ${straying.word} example.`;
|
|
67
|
+
case "Unshown":
|
|
68
|
+
return `The rule has no ${straying.word} example. Rules with one example of each kind judge best.`;
|
|
69
|
+
case "Mixed":
|
|
70
|
+
return straying.word === "should"
|
|
71
|
+
? `The description says "should", a guideline's word, but the rule is a requirement.`
|
|
72
|
+
: `The description says "${straying.word}", a requirement's word, but the rule is a guideline.`;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const displayPath = (file: string, root: string): string =>
|
|
77
|
+
file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
78
|
+
|
|
79
|
+
/** The wording part of `adhere validate`'s report: each rule that strays, then how. */
|
|
80
|
+
export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): ReadonlyArray<string> =>
|
|
81
|
+
strays.length === 0
|
|
82
|
+
? ["Every rule is worded as the rule writing tips recommend."]
|
|
83
|
+
: [
|
|
84
|
+
`${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
|
|
85
|
+
...strays.flatMap(({ entry, straying }) => [
|
|
86
|
+
` ${entry.id}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
|
|
87
|
+
...straying.map((one) => ` ${sentenceOf(one)}`),
|
|
88
|
+
]),
|
|
89
|
+
`Rule writing tips: ${TIPS_URL}`,
|
|
90
|
+
];
|
package/src/workflows/audit.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
|
|
2
|
+
import { type Excerpt, excerptOf } from "#excerpt.ts";
|
|
2
3
|
import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
3
4
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
4
|
-
import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
|
|
5
|
+
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
5
6
|
import {
|
|
6
7
|
fits,
|
|
7
8
|
Jev,
|
|
8
9
|
JevUnavailable,
|
|
10
|
+
type Judged,
|
|
9
11
|
judgeQuestion,
|
|
10
12
|
judgeRequests,
|
|
13
|
+
linterQuestion,
|
|
11
14
|
locateRequests,
|
|
12
|
-
|
|
15
|
+
questionRoom,
|
|
16
|
+
tokensOf,
|
|
13
17
|
} from "#services/Jev.ts";
|
|
14
18
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
19
|
+
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
15
20
|
import { applicableRules } from "#rules.ts";
|
|
16
|
-
import { type Crypto, Effect, Record } from "effect";
|
|
21
|
+
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
17
22
|
|
|
18
23
|
export interface Finding {
|
|
19
24
|
readonly rule: RuleId;
|
|
@@ -21,7 +26,8 @@ export interface Finding {
|
|
|
21
26
|
readonly examples: Examples;
|
|
22
27
|
readonly file: string;
|
|
23
28
|
readonly line: number;
|
|
24
|
-
|
|
29
|
+
/** The code around `line` the report shows. */
|
|
30
|
+
readonly excerpt: Excerpt;
|
|
25
31
|
readonly probability: number;
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -32,12 +38,21 @@ export interface AuditResult {
|
|
|
32
38
|
readonly skipped: number;
|
|
33
39
|
/** Files whose checks `--limit` left for a later run, none judged in this one. */
|
|
34
40
|
readonly waiting: number;
|
|
41
|
+
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
42
|
+
readonly blocked: ReadonlyArray<Blocked>;
|
|
35
43
|
readonly findings: ReadonlyArray<Finding>;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
|
|
47
|
+
export interface Blocked {
|
|
48
|
+
readonly file: string;
|
|
49
|
+
readonly ray: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
interface FileResult {
|
|
39
|
-
readonly status: "judged" | "cached" | "skipped" | "waiting";
|
|
53
|
+
readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
|
|
40
54
|
readonly findings: ReadonlyArray<Finding>;
|
|
55
|
+
readonly blocked?: Blocked;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
interface PreparedRule {
|
|
@@ -73,6 +88,8 @@ export interface FilePlan {
|
|
|
73
88
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
74
89
|
/** Pending checks `--limit` leaves for a later run. */
|
|
75
90
|
readonly deferred: number;
|
|
91
|
+
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
92
|
+
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
@@ -159,6 +176,7 @@ export const planAudit = (
|
|
|
159
176
|
const rules = configuredRules(file.path);
|
|
160
177
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
161
178
|
if (!fits(file.lines, rules)) {
|
|
179
|
+
yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
|
|
162
180
|
return {
|
|
163
181
|
file,
|
|
164
182
|
skipped: true,
|
|
@@ -167,6 +185,7 @@ export const planAudit = (
|
|
|
167
185
|
kept: {},
|
|
168
186
|
pending: {},
|
|
169
187
|
deferred: 0,
|
|
188
|
+
sampled: {},
|
|
170
189
|
} satisfies FilePlan;
|
|
171
190
|
}
|
|
172
191
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
@@ -179,13 +198,15 @@ export const planAudit = (
|
|
|
179
198
|
),
|
|
180
199
|
).pipe(Effect.map(Record.fromEntries));
|
|
181
200
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
201
|
+
const answers = yield* cache.get(hash, file.path);
|
|
202
|
+
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
203
|
+
const answer = answers[fingerprint];
|
|
204
|
+
return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
|
|
205
|
+
});
|
|
188
206
|
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
207
|
+
yield* Effect.logTrace(
|
|
208
|
+
`plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
|
|
209
|
+
);
|
|
189
210
|
return {
|
|
190
211
|
file,
|
|
191
212
|
skipped: false,
|
|
@@ -194,15 +215,78 @@ export const planAudit = (
|
|
|
194
215
|
kept,
|
|
195
216
|
pending,
|
|
196
217
|
deferred: 0,
|
|
218
|
+
sampled: {},
|
|
197
219
|
} satisfies FilePlan;
|
|
198
220
|
});
|
|
199
221
|
|
|
222
|
+
// Preset rules are not the project's to change, so the linter check leaves them out.
|
|
223
|
+
const presetRules = new Set(
|
|
224
|
+
(config.scopedRules ?? []).flatMap((entry) =>
|
|
225
|
+
entry.preset === undefined ? [] : [entry.rule],
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The linter check's samples: for each project rule its tally still needs
|
|
231
|
+
* answers for, files this run judges it on, spread evenly over them in path
|
|
232
|
+
* order, skipping files the tally has and files its question would not fit.
|
|
233
|
+
*/
|
|
234
|
+
const withSamples = Effect.fn("audit.samples")(function* (plans: ReadonlyArray<FilePlan>) {
|
|
235
|
+
const needs = new Map<Rule, { readonly key: string; readonly tallied: Tally["files"] }>();
|
|
236
|
+
for (const plan of plans) {
|
|
237
|
+
for (const { rule } of Object.values(plan.pending)) {
|
|
238
|
+
if (presetRules.has(rule) || needs.has(rule)) continue;
|
|
239
|
+
const key = yield* tallyKeyOf(config.model, rule);
|
|
240
|
+
needs.set(rule, { key, tallied: (yield* cache.tally(key))?.files ?? {} });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const [rule, { tallied }] of needs) {
|
|
244
|
+
if (Object.keys(tallied).length >= SAMPLE) needs.delete(rule);
|
|
245
|
+
}
|
|
246
|
+
if (needs.size === 0) return plans;
|
|
247
|
+
|
|
248
|
+
const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
|
|
249
|
+
const tokens = new Map<Rule, number>();
|
|
250
|
+
for (const plan of plans) {
|
|
251
|
+
let room: number | undefined;
|
|
252
|
+
for (const [id, { rule }] of Object.entries(plan.pending)) {
|
|
253
|
+
const need = needs.get(rule);
|
|
254
|
+
if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
|
|
255
|
+
room ??= questionRoom(plan.file.lines);
|
|
256
|
+
const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
|
|
257
|
+
tokens.set(rule, size);
|
|
258
|
+
if (size > room) continue;
|
|
259
|
+
const found = candidates.get(rule);
|
|
260
|
+
if (found === undefined) candidates.set(rule, [{ plan, id }]);
|
|
261
|
+
else found.push({ plan, id });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sampled = new Map<FilePlan, Record<RuleId, string>>();
|
|
266
|
+
for (const [rule, found] of candidates) {
|
|
267
|
+
const need = needs.get(rule);
|
|
268
|
+
if (need === undefined) continue;
|
|
269
|
+
const count = Math.min(found.length, SAMPLE - Object.keys(need.tallied).length);
|
|
270
|
+
for (let index = 0; index < count; index++) {
|
|
271
|
+
const pick = found[Math.floor((index * found.length) / count)];
|
|
272
|
+
if (pick !== undefined) {
|
|
273
|
+
sampled.set(pick.plan, { ...sampled.get(pick.plan), [pick.id]: need.key });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return plans.map((plan) => {
|
|
278
|
+
const some = sampled.get(plan);
|
|
279
|
+
return some === undefined ? plan : { ...plan, sampled: some };
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
200
283
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
201
284
|
const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
202
285
|
concurrency: 8,
|
|
203
286
|
});
|
|
204
|
-
const
|
|
287
|
+
const limited =
|
|
205
288
|
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
289
|
+
const planned = yield* withSamples(limited);
|
|
206
290
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
207
291
|
const total = (count: (plan: FilePlan) => number) =>
|
|
208
292
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
@@ -214,7 +298,9 @@ export const planAudit = (
|
|
|
214
298
|
skipped: planned.length - judged.length,
|
|
215
299
|
deferred: total((plan) => plan.deferred),
|
|
216
300
|
requests: total((plan) =>
|
|
217
|
-
isEmpty(plan.pending)
|
|
301
|
+
isEmpty(plan.pending)
|
|
302
|
+
? 0
|
|
303
|
+
: judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
218
304
|
),
|
|
219
305
|
};
|
|
220
306
|
});
|
|
@@ -230,6 +316,29 @@ export const executeAudit = (
|
|
|
230
316
|
Effect.gen(function* () {
|
|
231
317
|
const jev = yield* Jev;
|
|
232
318
|
const cache = yield* AuditCache;
|
|
319
|
+
// Files finish together; each tally is read, extended, and written back by one file at a time.
|
|
320
|
+
const tallying = yield* Semaphore.make(1);
|
|
321
|
+
|
|
322
|
+
/** Adds one file's linter check answers to their rules' tallies. */
|
|
323
|
+
const record = (
|
|
324
|
+
file: string,
|
|
325
|
+
sampled: Readonly<Record<RuleId, string>>,
|
|
326
|
+
linter: Readonly<Record<RuleId, number>>,
|
|
327
|
+
) =>
|
|
328
|
+
tallying.withPermits(1)(
|
|
329
|
+
Effect.forEach(
|
|
330
|
+
Object.entries(linter),
|
|
331
|
+
([id, probability]) => {
|
|
332
|
+
const key = sampled[id];
|
|
333
|
+
return key === undefined
|
|
334
|
+
? Effect.void
|
|
335
|
+
: Effect.flatMap(cache.tally(key), (tally) =>
|
|
336
|
+
cache.putTally(key, { files: { ...tally?.files, [file]: probability } }),
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
{ discard: true },
|
|
340
|
+
),
|
|
341
|
+
);
|
|
233
342
|
|
|
234
343
|
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
235
344
|
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
@@ -239,10 +348,12 @@ export const executeAudit = (
|
|
|
239
348
|
kept,
|
|
240
349
|
pending,
|
|
241
350
|
deferred,
|
|
351
|
+
sampled,
|
|
242
352
|
}: FilePlan) {
|
|
243
|
-
const probabilities = isEmpty(pending)
|
|
244
|
-
? {}
|
|
245
|
-
: yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
|
|
353
|
+
const { probabilities, linter }: Judged = isEmpty(pending)
|
|
354
|
+
? { probabilities: {}, linter: {} }
|
|
355
|
+
: yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
|
|
356
|
+
if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
|
|
246
357
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
247
358
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
248
359
|
const k = pending[id];
|
|
@@ -257,18 +368,29 @@ export const executeAudit = (
|
|
|
257
368
|
judgment.line === undefined
|
|
258
369
|
);
|
|
259
370
|
});
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
371
|
+
// A blocked line question still leaves the judgments worth caching: a rerun
|
|
372
|
+
// asks only for the lines again, not for every rule.
|
|
373
|
+
const {
|
|
374
|
+
lines,
|
|
375
|
+
blocked,
|
|
376
|
+
}: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
|
|
377
|
+
flagged,
|
|
378
|
+
)
|
|
379
|
+
? { lines: {}, blocked: undefined }
|
|
380
|
+
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
381
|
+
Effect.map((lines) => ({ lines, blocked: undefined })),
|
|
382
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
383
|
+
Effect.succeed({ lines: {}, blocked: { file: file.path, ray } }),
|
|
384
|
+
),
|
|
385
|
+
inFile(file),
|
|
386
|
+
);
|
|
263
387
|
const located = Record.map(judged, (judgment, id) => {
|
|
264
388
|
const line = lines[id];
|
|
265
|
-
return line === undefined
|
|
266
|
-
? judgment
|
|
267
|
-
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
389
|
+
return line === undefined ? judgment : { ...judgment, line };
|
|
268
390
|
});
|
|
269
391
|
|
|
270
392
|
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
271
|
-
if (!cached) yield* cache.put(
|
|
393
|
+
if (!cached) yield* cache.put(hash, answersOf(located));
|
|
272
394
|
|
|
273
395
|
const findings = Object.entries(located)
|
|
274
396
|
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
@@ -283,7 +405,7 @@ export const executeAudit = (
|
|
|
283
405
|
examples: examplesOf(k.rule),
|
|
284
406
|
file: file.path,
|
|
285
407
|
line: judgment.line,
|
|
286
|
-
|
|
408
|
+
excerpt: excerptOf(file.lines, judgment.line),
|
|
287
409
|
probability: judgment.probability,
|
|
288
410
|
},
|
|
289
411
|
]
|
|
@@ -291,25 +413,60 @@ export const executeAudit = (
|
|
|
291
413
|
})
|
|
292
414
|
.sort((a, b) => a.line - b.line);
|
|
293
415
|
return {
|
|
294
|
-
result:
|
|
416
|
+
result:
|
|
417
|
+
blocked === undefined
|
|
418
|
+
? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
|
|
419
|
+
: { status: "blocked", findings, blocked },
|
|
295
420
|
requests:
|
|
296
|
-
(isEmpty(pending)
|
|
421
|
+
(isEmpty(pending)
|
|
422
|
+
? 0
|
|
423
|
+
: judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
|
|
297
424
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
298
425
|
};
|
|
299
426
|
});
|
|
300
427
|
|
|
301
428
|
const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
|
|
302
|
-
const
|
|
303
|
-
? { result: fileResult("skipped", []), requests: 0 }
|
|
304
|
-
:
|
|
429
|
+
const judging = plan.skipped
|
|
430
|
+
? Effect.succeed({ result: fileResult("skipped", []), requests: 0 })
|
|
431
|
+
: judgeFile(plan).pipe(
|
|
305
432
|
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
306
433
|
Effect.catchTag("JevOverflow", () =>
|
|
307
434
|
Effect.succeed({
|
|
308
435
|
result: fileResult("skipped", []),
|
|
309
|
-
requests: judgeRequests(
|
|
436
|
+
requests: judgeRequests(
|
|
437
|
+
plan.file.lines,
|
|
438
|
+
rulesOf(plan.pending),
|
|
439
|
+
Object.keys(plan.sampled),
|
|
440
|
+
),
|
|
441
|
+
}),
|
|
442
|
+
),
|
|
443
|
+
// The firewall refuses this file's code every time; the rest of the run goes on.
|
|
444
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
445
|
+
Effect.succeed({
|
|
446
|
+
result: {
|
|
447
|
+
status: "blocked",
|
|
448
|
+
findings: [],
|
|
449
|
+
blocked: { file: plan.file.path, ray },
|
|
450
|
+
} satisfies FileResult,
|
|
451
|
+
requests: judgeRequests(
|
|
452
|
+
plan.file.lines,
|
|
453
|
+
rulesOf(plan.pending),
|
|
454
|
+
Object.keys(plan.sampled),
|
|
455
|
+
),
|
|
310
456
|
}),
|
|
311
457
|
),
|
|
312
458
|
);
|
|
459
|
+
// Everything logged while judging a file, requests included, names the file.
|
|
460
|
+
// A file that sent nothing, whether cached or too long, is a trace line: a
|
|
461
|
+
// large repo's cache would otherwise bury the requests.
|
|
462
|
+
const [, { result, requests }] = yield* Effect.timed(judging).pipe(
|
|
463
|
+
Effect.tap(([elapsed, { result, requests }]) =>
|
|
464
|
+
(requests > 0 ? Effect.logDebug : Effect.logTrace)(
|
|
465
|
+
`${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
Effect.annotateLogs("file", plan.file.path),
|
|
469
|
+
);
|
|
313
470
|
yield* progress({ requests, findings: result.findings.length });
|
|
314
471
|
return result;
|
|
315
472
|
});
|
|
@@ -323,10 +480,33 @@ export const executeAudit = (
|
|
|
323
480
|
cached: count("cached"),
|
|
324
481
|
skipped: count("skipped"),
|
|
325
482
|
waiting: count("waiting"),
|
|
483
|
+
blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
|
|
326
484
|
findings: results.flatMap((result) => result.findings),
|
|
327
485
|
};
|
|
328
486
|
});
|
|
329
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Deletes from the cache what the files and rules of a run no longer need:
|
|
490
|
+
* answers about content no file has, answers to rule texts no rule asks, and
|
|
491
|
+
* tallies of rules gone. Only after a run that read every file; a run narrowed
|
|
492
|
+
* by `--filter` cannot tell what the files it left out need.
|
|
493
|
+
*/
|
|
494
|
+
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
495
|
+
const config = yield* AdhereConfig;
|
|
496
|
+
const cache = yield* AuditCache;
|
|
497
|
+
const read = plan.files.filter((file) => !file.skipped);
|
|
498
|
+
const prepared = read.flatMap((file) => Object.values(file.prepared));
|
|
499
|
+
const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
|
|
500
|
+
tallyKeyOf(config.model, rule),
|
|
501
|
+
);
|
|
502
|
+
const deleted = yield* cache.prune({
|
|
503
|
+
hashes: new Set(read.map((file) => file.hash)),
|
|
504
|
+
fingerprints: new Set(prepared.map(({ fingerprint }) => fingerprint)),
|
|
505
|
+
tallies: new Set(tallies),
|
|
506
|
+
});
|
|
507
|
+
yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
|
|
508
|
+
});
|
|
509
|
+
|
|
330
510
|
/** Plans the run and executes it, without asking and without a counter. */
|
|
331
511
|
export const runAudit: Effect.Effect<
|
|
332
512
|
AuditResult,
|
package/src/workflows/format.ts
CHANGED
|
@@ -6,7 +6,15 @@ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit
|
|
|
6
6
|
* token of code, named by its role. Layout measures text; only `serialize`
|
|
7
7
|
* turns a role into color, so no width or blank line ever sees an escape code.
|
|
8
8
|
*/
|
|
9
|
-
type Role =
|
|
9
|
+
type Role =
|
|
10
|
+
| "error"
|
|
11
|
+
| "probability"
|
|
12
|
+
| "description"
|
|
13
|
+
| "path"
|
|
14
|
+
| "lineNumber"
|
|
15
|
+
| "underline"
|
|
16
|
+
| "label"
|
|
17
|
+
| Kind;
|
|
10
18
|
|
|
11
19
|
interface Span {
|
|
12
20
|
readonly text: string;
|
|
@@ -16,17 +24,23 @@ interface Span {
|
|
|
16
24
|
type Line = ReadonlyArray<Span>;
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
* SGR parameters per role. The frame's
|
|
20
|
-
* (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* SGR parameters per role. The frame's are set colors, from the 256-color
|
|
28
|
+
* palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
29
|
+
* and a dim line number. Of the header, only the `×` and the rule are red,
|
|
30
|
+
* since a whole line of red is hard to read: the probability is an accent, and
|
|
31
|
+
* the description a cool near-white, set here since a theme's own white can
|
|
32
|
+
* be gray. The code's are the terminal's own colors (`3N`), so the user's
|
|
33
|
+
* theme picks shades that read on its background; none is magenta, which
|
|
34
|
+
* would run into the underline.
|
|
23
35
|
*/
|
|
24
36
|
const THEME: Readonly<Record<Role, string>> = {
|
|
25
|
-
error: "38;2;
|
|
37
|
+
error: "38;2;164;20;71;1",
|
|
38
|
+
probability: "38;5;156",
|
|
39
|
+
description: "38;2;230;230;255",
|
|
26
40
|
path: "38;2;5;125;160;1",
|
|
27
41
|
lineNumber: "2",
|
|
28
42
|
underline: "38;2;255;0;175",
|
|
29
|
-
label: "38;2;
|
|
43
|
+
label: "38;2;242;205;205",
|
|
30
44
|
comment: "2",
|
|
31
45
|
string: "32",
|
|
32
46
|
constant: "33",
|
|
@@ -77,22 +91,40 @@ const header = (finding: Finding): Line => [
|
|
|
77
91
|
span(" "),
|
|
78
92
|
span("×", "error"),
|
|
79
93
|
span(" "),
|
|
80
|
-
span(
|
|
81
|
-
span("
|
|
82
|
-
span(finding.
|
|
94
|
+
span(finding.rule, "error"),
|
|
95
|
+
span(" ("),
|
|
96
|
+
span(finding.probability.toFixed(2), "probability"),
|
|
97
|
+
span("): "),
|
|
98
|
+
span(finding.description, "description"),
|
|
83
99
|
];
|
|
84
100
|
|
|
85
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* The path, then the finding's excerpt with each line numbered in the gutter,
|
|
103
|
+
* and an underline under the offending line's code.
|
|
104
|
+
*/
|
|
86
105
|
const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
106
|
+
const { start, lines } = finding.excerpt;
|
|
107
|
+
const width = String(start + lines.length - 1).length;
|
|
108
|
+
const gutter = span(" ".repeat(width + 2));
|
|
109
|
+
const offending = lines[finding.line - start] ?? "";
|
|
110
|
+
// The line's own indentation, tabs and all, lines the underline up under its code.
|
|
111
|
+
const indent = offending.slice(0, offending.length - offending.trimStart().length);
|
|
112
|
+
const underline = "─".repeat(Math.max(1, offending.trim().length));
|
|
113
|
+
const location = `:${finding.line}:${indent.length + 1}]`;
|
|
92
114
|
return [
|
|
93
|
-
[gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
[gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
|
|
116
|
+
...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
|
|
117
|
+
const number = String(start + index);
|
|
118
|
+
const numbered = [
|
|
119
|
+
span(" ".repeat(width - number.length + 1)),
|
|
120
|
+
span(number, "lineNumber"),
|
|
121
|
+
span(" │ "),
|
|
122
|
+
...code,
|
|
123
|
+
];
|
|
124
|
+
return start + index === finding.line
|
|
125
|
+
? [numbered, [gutter, span("· "), span(indent), span(underline, "underline")]]
|
|
126
|
+
: [numbered];
|
|
127
|
+
}),
|
|
96
128
|
[gutter, span("╰────")],
|
|
97
129
|
];
|
|
98
130
|
};
|
|
@@ -109,8 +141,9 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
|
|
|
109
141
|
};
|
|
110
142
|
|
|
111
143
|
/**
|
|
112
|
-
* One diagnostic, in the frame `vp lint` prints on a terminal: a
|
|
113
|
-
* the
|
|
144
|
+
* One diagnostic, in the frame `vp lint` prints on a terminal: a header with
|
|
145
|
+
* the rule in red, the code around the offending line, a pink underline under
|
|
146
|
+
* it, and the code to write as the hint.
|
|
114
147
|
*/
|
|
115
148
|
const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
|
|
116
149
|
header(finding),
|
|
@@ -125,6 +158,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
125
158
|
`${result.cached} cached`,
|
|
126
159
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
127
160
|
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
161
|
+
...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
|
|
128
162
|
];
|
|
129
163
|
return [
|
|
130
164
|
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
@@ -132,6 +166,27 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
132
166
|
];
|
|
133
167
|
};
|
|
134
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The files the firewall in front of Jev's API refused, each with the Ray ID
|
|
171
|
+
* TypeSafe AI can look the block up by, so a gap in the audit says where it is.
|
|
172
|
+
*/
|
|
173
|
+
const blockedFiles = (result: AuditResult, root: string | undefined): ReadonlyArray<Line> =>
|
|
174
|
+
result.blocked.length === 0
|
|
175
|
+
? []
|
|
176
|
+
: [
|
|
177
|
+
[],
|
|
178
|
+
[
|
|
179
|
+
span(
|
|
180
|
+
`The firewall in front of Jev's API blocked ${counted(result.blocked.length, "file", "files")}, so ${result.blocked.length === 1 ? "it is" : "they are"} not judged in full. TypeSafe AI can look each block up by its Ray ID:`,
|
|
181
|
+
),
|
|
182
|
+
],
|
|
183
|
+
...result.blocked.map(({ file, ray }) => [
|
|
184
|
+
span(" "),
|
|
185
|
+
span(displayPath(file, root), "path"),
|
|
186
|
+
span(` (Ray ID ${ray})`),
|
|
187
|
+
]),
|
|
188
|
+
];
|
|
189
|
+
|
|
135
190
|
export interface RenderOptions {
|
|
136
191
|
/** Color the frame the way `vp lint` does on a terminal, and highlight the code. */
|
|
137
192
|
readonly color?: boolean;
|
|
@@ -147,6 +202,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
147
202
|
[
|
|
148
203
|
...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
|
|
149
204
|
...summary(result),
|
|
205
|
+
...blockedFiles(result, options.root),
|
|
150
206
|
].map((line) => serialize(line, options.color === true));
|
|
151
207
|
|
|
152
208
|
/**
|