@drkmttr/adhere 0.7.3 → 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/README.md +145 -32
- package/package.json +12 -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 +66 -12
- package/src/services/Jev.ts +66 -6
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +169 -26
- package/src/workflows/format.ts +35 -18
- 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
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
|
|
|
@@ -82,6 +88,8 @@ export interface FilePlan {
|
|
|
82
88
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
83
89
|
/** Pending checks `--limit` leaves for a later run. */
|
|
84
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>>;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
@@ -168,6 +176,7 @@ export const planAudit = (
|
|
|
168
176
|
const rules = configuredRules(file.path);
|
|
169
177
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
170
178
|
if (!fits(file.lines, rules)) {
|
|
179
|
+
yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
|
|
171
180
|
return {
|
|
172
181
|
file,
|
|
173
182
|
skipped: true,
|
|
@@ -176,6 +185,7 @@ export const planAudit = (
|
|
|
176
185
|
kept: {},
|
|
177
186
|
pending: {},
|
|
178
187
|
deferred: 0,
|
|
188
|
+
sampled: {},
|
|
179
189
|
} satisfies FilePlan;
|
|
180
190
|
}
|
|
181
191
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
@@ -188,13 +198,15 @@ export const planAudit = (
|
|
|
188
198
|
),
|
|
189
199
|
).pipe(Effect.map(Record.fromEntries));
|
|
190
200
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
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
|
+
});
|
|
197
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
|
+
);
|
|
198
210
|
return {
|
|
199
211
|
file,
|
|
200
212
|
skipped: false,
|
|
@@ -203,15 +215,78 @@ export const planAudit = (
|
|
|
203
215
|
kept,
|
|
204
216
|
pending,
|
|
205
217
|
deferred: 0,
|
|
218
|
+
sampled: {},
|
|
206
219
|
} satisfies FilePlan;
|
|
207
220
|
});
|
|
208
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
|
+
|
|
209
283
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
210
284
|
const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
211
285
|
concurrency: 8,
|
|
212
286
|
});
|
|
213
|
-
const
|
|
287
|
+
const limited =
|
|
214
288
|
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
289
|
+
const planned = yield* withSamples(limited);
|
|
215
290
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
216
291
|
const total = (count: (plan: FilePlan) => number) =>
|
|
217
292
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
@@ -223,7 +298,9 @@ export const planAudit = (
|
|
|
223
298
|
skipped: planned.length - judged.length,
|
|
224
299
|
deferred: total((plan) => plan.deferred),
|
|
225
300
|
requests: total((plan) =>
|
|
226
|
-
isEmpty(plan.pending)
|
|
301
|
+
isEmpty(plan.pending)
|
|
302
|
+
? 0
|
|
303
|
+
: judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
227
304
|
),
|
|
228
305
|
};
|
|
229
306
|
});
|
|
@@ -239,6 +316,29 @@ export const executeAudit = (
|
|
|
239
316
|
Effect.gen(function* () {
|
|
240
317
|
const jev = yield* Jev;
|
|
241
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
|
+
);
|
|
242
342
|
|
|
243
343
|
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
244
344
|
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
@@ -248,10 +348,12 @@ export const executeAudit = (
|
|
|
248
348
|
kept,
|
|
249
349
|
pending,
|
|
250
350
|
deferred,
|
|
351
|
+
sampled,
|
|
251
352
|
}: FilePlan) {
|
|
252
|
-
const probabilities = isEmpty(pending)
|
|
253
|
-
? {}
|
|
254
|
-
: 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);
|
|
255
357
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
256
358
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
257
359
|
const k = pending[id];
|
|
@@ -284,13 +386,11 @@ export const executeAudit = (
|
|
|
284
386
|
);
|
|
285
387
|
const located = Record.map(judged, (judgment, id) => {
|
|
286
388
|
const line = lines[id];
|
|
287
|
-
return line === undefined
|
|
288
|
-
? judgment
|
|
289
|
-
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
389
|
+
return line === undefined ? judgment : { ...judgment, line };
|
|
290
390
|
});
|
|
291
391
|
|
|
292
392
|
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
293
|
-
if (!cached) yield* cache.put(
|
|
393
|
+
if (!cached) yield* cache.put(hash, answersOf(located));
|
|
294
394
|
|
|
295
395
|
const findings = Object.entries(located)
|
|
296
396
|
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
@@ -305,7 +405,7 @@ export const executeAudit = (
|
|
|
305
405
|
examples: examplesOf(k.rule),
|
|
306
406
|
file: file.path,
|
|
307
407
|
line: judgment.line,
|
|
308
|
-
|
|
408
|
+
excerpt: excerptOf(file.lines, judgment.line),
|
|
309
409
|
probability: judgment.probability,
|
|
310
410
|
},
|
|
311
411
|
]
|
|
@@ -318,20 +418,26 @@ export const executeAudit = (
|
|
|
318
418
|
? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
|
|
319
419
|
: { status: "blocked", findings, blocked },
|
|
320
420
|
requests:
|
|
321
|
-
(isEmpty(pending)
|
|
421
|
+
(isEmpty(pending)
|
|
422
|
+
? 0
|
|
423
|
+
: judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
|
|
322
424
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
323
425
|
};
|
|
324
426
|
});
|
|
325
427
|
|
|
326
428
|
const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
|
|
327
|
-
const
|
|
328
|
-
? { result: fileResult("skipped", []), requests: 0 }
|
|
329
|
-
:
|
|
429
|
+
const judging = plan.skipped
|
|
430
|
+
? Effect.succeed({ result: fileResult("skipped", []), requests: 0 })
|
|
431
|
+
: judgeFile(plan).pipe(
|
|
330
432
|
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
331
433
|
Effect.catchTag("JevOverflow", () =>
|
|
332
434
|
Effect.succeed({
|
|
333
435
|
result: fileResult("skipped", []),
|
|
334
|
-
requests: judgeRequests(
|
|
436
|
+
requests: judgeRequests(
|
|
437
|
+
plan.file.lines,
|
|
438
|
+
rulesOf(plan.pending),
|
|
439
|
+
Object.keys(plan.sampled),
|
|
440
|
+
),
|
|
335
441
|
}),
|
|
336
442
|
),
|
|
337
443
|
// The firewall refuses this file's code every time; the rest of the run goes on.
|
|
@@ -342,10 +448,25 @@ export const executeAudit = (
|
|
|
342
448
|
findings: [],
|
|
343
449
|
blocked: { file: plan.file.path, ray },
|
|
344
450
|
} satisfies FileResult,
|
|
345
|
-
requests: judgeRequests(
|
|
451
|
+
requests: judgeRequests(
|
|
452
|
+
plan.file.lines,
|
|
453
|
+
rulesOf(plan.pending),
|
|
454
|
+
Object.keys(plan.sampled),
|
|
455
|
+
),
|
|
346
456
|
}),
|
|
347
457
|
),
|
|
348
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
|
+
);
|
|
349
470
|
yield* progress({ requests, findings: result.findings.length });
|
|
350
471
|
return result;
|
|
351
472
|
});
|
|
@@ -364,6 +485,28 @@ export const executeAudit = (
|
|
|
364
485
|
};
|
|
365
486
|
});
|
|
366
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
|
+
|
|
367
510
|
/** Plans the run and executes it, without asking and without a counter. */
|
|
368
511
|
export const runAudit: Effect.Effect<
|
|
369
512
|
AuditResult,
|
package/src/workflows/format.ts
CHANGED
|
@@ -28,18 +28,19 @@ type Line = ReadonlyArray<Span>;
|
|
|
28
28
|
* palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
29
29
|
* and a dim line number. Of the header, only the `×` and the rule are red,
|
|
30
30
|
* since a whole line of red is hard to read: the probability is an accent, and
|
|
31
|
-
* the description a
|
|
32
|
-
* (`3N`), so the user's
|
|
33
|
-
*
|
|
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.
|
|
34
35
|
*/
|
|
35
36
|
const THEME: Readonly<Record<Role, string>> = {
|
|
36
|
-
error: "38;
|
|
37
|
-
probability: "38;
|
|
38
|
-
description: "38;2;
|
|
37
|
+
error: "38;2;164;20;71;1",
|
|
38
|
+
probability: "38;5;156",
|
|
39
|
+
description: "38;2;230;230;255",
|
|
39
40
|
path: "38;2;5;125;160;1",
|
|
40
41
|
lineNumber: "2",
|
|
41
42
|
underline: "38;2;255;0;175",
|
|
42
|
-
label: "38;
|
|
43
|
+
label: "38;2;242;205;205",
|
|
43
44
|
comment: "2",
|
|
44
45
|
string: "32",
|
|
45
46
|
constant: "33",
|
|
@@ -97,17 +98,33 @@ const header = (finding: Finding): Line => [
|
|
|
97
98
|
span(finding.description, "description"),
|
|
98
99
|
];
|
|
99
100
|
|
|
100
|
-
/**
|
|
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
|
+
*/
|
|
101
105
|
const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
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}]`;
|
|
107
114
|
return [
|
|
108
|
-
[gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}),
|
|
111
128
|
[gutter, span("╰────")],
|
|
112
129
|
];
|
|
113
130
|
};
|
|
@@ -125,8 +142,8 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
|
|
|
125
142
|
|
|
126
143
|
/**
|
|
127
144
|
* One diagnostic, in the frame `vp lint` prints on a terminal: a header with
|
|
128
|
-
* the rule in red, the offending line, a pink underline
|
|
129
|
-
* as the hint.
|
|
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.
|
|
130
147
|
*/
|
|
131
148
|
const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
|
|
132
149
|
header(finding),
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: A named function that returns an Effect must be defined with Effect.fn so the call site is traced. An Effect held in a constant, such as `const program = Effect.gen(...)`, is not a function and is not in scope.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
const processUser = Effect.fn("processUser")(function* (userId: string) {
|
|
7
|
-
yield* Effect.logInfo(`Processing user ${userId}`);
|
|
8
|
-
const user = yield* getUser(userId);
|
|
9
|
-
return yield* processData(user);
|
|
10
|
-
});
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
```ts never
|
|
14
|
-
const loadUser = (id: string) =>
|
|
15
|
-
Effect.gen(function* () {
|
|
16
|
-
const repo = yield* UserRepo;
|
|
17
|
-
return yield* repo.find(id);
|
|
18
|
-
});
|
|
19
|
-
```
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: JSON crossing a boundary must be decoded with Schema.fromJsonString, never with JSON.parse followed by a cast.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
const MoveFromJson = Schema.fromJsonString(Move);
|
|
7
|
-
|
|
8
|
-
const program = Effect.gen(function* () {
|
|
9
|
-
const jsonString = '{"from":{"row":"A","column":"1"},"to":{"row":"B","column":"2"}}';
|
|
10
|
-
const move = yield* Schema.decodeUnknownEffect(MoveFromJson)(jsonString);
|
|
11
|
-
const json = yield* Schema.encodeEffect(MoveFromJson)(move);
|
|
12
|
-
return json;
|
|
13
|
-
});
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
```ts never
|
|
17
|
-
const settings = JSON.parse(text) as Settings;
|
|
18
|
-
```
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: A domain failure must be a Schema.TaggedError with its own tag, never a bare Error or a plain object.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
|
|
7
|
-
field: Schema.String,
|
|
8
|
-
message: Schema.String,
|
|
9
|
-
}) {}
|
|
10
|
-
|
|
11
|
-
class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {
|
|
12
|
-
resource: Schema.String,
|
|
13
|
-
id: Schema.String,
|
|
14
|
-
}) {}
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
```ts never
|
|
18
|
-
return yield* Effect.fail(new Error("Card declined"));
|
|
19
|
-
```
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: File, path, process, terminal, and HTTP access must go through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, never through node: builtins, Bun globals, or fetch. The Bun implementations must be provided once at the entry point with BunServices.layer.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
import { Effect, FileSystem, Path } from "effect";
|
|
7
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
8
|
-
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
9
|
-
|
|
10
|
-
const program = Effect.gen(function* () {
|
|
11
|
-
const fs = yield* FileSystem.FileSystem;
|
|
12
|
-
const path = yield* Path.Path;
|
|
13
|
-
const config = yield* fs.readFileString(path.join(path.resolve(), "config.json"));
|
|
14
|
-
const response = yield* HttpClient.get("https://api.example.com/users");
|
|
15
|
-
const users = yield* HttpClientResponse.schemaBodyJson(Users)(response);
|
|
16
|
-
return { config, users };
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
program.pipe(Effect.provide(BunServices.layer), BunRuntime.runMain);
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
```ts never
|
|
23
|
-
import { readFileSync } from "node:fs";
|
|
24
|
-
|
|
25
|
-
const settings = readFileSync("settings.json", "utf8");
|
|
26
|
-
```
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Service method signatures must have no requirements. Dependencies must be acquired inside the layer, never declared on the method.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
class Database extends Context.Service<
|
|
7
|
-
Database,
|
|
8
|
-
{
|
|
9
|
-
readonly query: (sql: string) => Effect.Effect<unknown[]>;
|
|
10
|
-
readonly execute: (sql: string) => Effect.Effect<void>;
|
|
11
|
-
}
|
|
12
|
-
>()("@app/Database") {}
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
```ts never
|
|
16
|
-
class Orders extends Context.Service<
|
|
17
|
-
Orders,
|
|
18
|
-
{ readonly save: (order: Order) => Effect.Effect<void, SaveError, Database> }
|
|
19
|
-
>()("@app/Orders") {}
|
|
20
|
-
```
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Layers must be provided once at the program entry. A module that is not an entry point must never call Effect.provide.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
const appLayer = userServiceLayer.pipe(
|
|
7
|
-
Layer.provideMerge(databaseLayer),
|
|
8
|
-
Layer.provideMerge(loggerLayer),
|
|
9
|
-
Layer.provideMerge(configLayer),
|
|
10
|
-
);
|
|
11
|
-
|
|
12
|
-
const program = Effect.gen(function* () {
|
|
13
|
-
const users = yield* UserService;
|
|
14
|
-
yield* users.getUser();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const main = program.pipe(Effect.provide(appLayer));
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
```ts never
|
|
21
|
-
export const getUser = (id: string) =>
|
|
22
|
-
UserRepo.find(id).pipe(Effect.provide(UserRepo.layer));
|
|
23
|
-
```
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: A test that depends on randomness must control it with a fixed seed through Random.withSeed, never with Math.random or an unseeded Random.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
const program = Effect.gen(function* () {
|
|
7
|
-
const value1 = yield* Random.next;
|
|
8
|
-
const value2 = yield* Random.next;
|
|
9
|
-
return [value1, value2];
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
await Effect.runPromise(
|
|
13
|
-
Effect.all([program.pipe(Random.withSeed("my-seed")), program.pipe(Random.withSeed("my-seed"))]),
|
|
14
|
-
); // => [[0.018368576514773527, 0.4010840628128671], [0.018368576514773527, 0.4010840628128671]]
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
```ts never
|
|
18
|
-
it("creates unique ids", () => {
|
|
19
|
-
const id = Math.random().toString(36).slice(2);
|
|
20
|
-
expect(id).toHaveLength(11);
|
|
21
|
-
});
|
|
22
|
-
```
|