@drkmttr/adhere 0.5.0 → 0.6.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 +41 -11
- package/package.json +6 -6
- package/presets/effect/basics/fn-for-named-effects.md +1 -1
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/basics/instrument-with-pipe.md +1 -1
- package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
- package/presets/effect/config/secrets-are-redacted.md +1 -1
- package/presets/effect/config/validate-with-schema.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/decode-json-with-schema.md +1 -1
- package/presets/effect/data/records-are-schema-classes.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
- package/presets/effect/errors/defects-for-bugs.md +1 -1
- package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
- package/presets/effect/errors/wrap-external-errors.md +1 -1
- package/presets/effect/platform/os-access-through-platform-services.md +1 -1
- package/presets/effect/services/fresh-layer-per-test.md +1 -1
- package/presets/effect/services/memoize-parameterized-layers.md +1 -1
- package/presets/effect/services/methods-have-no-requirements.md +1 -1
- package/presets/effect/services/provide-at-entry.md +1 -1
- package/presets/effect/services/test-layers-are-in-memory.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/src/cli.ts +73 -3
- package/src/services/Jev.http.ts +52 -11
- package/src/services/Jev.ts +121 -37
- package/src/workflows/audit.ts +195 -79
- package/src/workflows/format.ts +42 -1
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ refusal prints its reason.
|
|
|
68
68
|
adhere lint # audit the working directory
|
|
69
69
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
70
70
|
adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
|
|
71
|
+
adhere lint --yes # send the requests without asking first
|
|
71
72
|
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
72
73
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
73
74
|
adhere login # save a TypeSafe AI API key for later runs
|
|
@@ -79,6 +80,24 @@ Bare `adhere` prints the help, which lists the commands, and
|
|
|
79
80
|
`adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
|
|
80
81
|
prints a completion script.
|
|
81
82
|
|
|
83
|
+
### Before and during a run
|
|
84
|
+
|
|
85
|
+
Before it sends anything, `lint` says on stderr what it found and what judging
|
|
86
|
+
takes:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
200 files and 14 rules: 2800 checks, 1400 cached.
|
|
90
|
+
Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
|
|
91
|
+
? Send 200 requests to Jev? › (Y/n)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
It asks only with a terminal on stdin and stdout, and never when the cache
|
|
95
|
+
answers every check; `--yes` sends without asking. While it runs, a counter on
|
|
96
|
+
stderr shows the files done, the requests sent, and the findings so far, and
|
|
97
|
+
the report goes to stdout. A request Jev refuses stops the run with the file,
|
|
98
|
+
the HTTP status, and Jev's reason. Files finished before it are cached, so
|
|
99
|
+
running again continues from there.
|
|
100
|
+
|
|
82
101
|
### What gets read
|
|
83
102
|
|
|
84
103
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
|
@@ -141,7 +160,7 @@ export default {
|
|
|
141
160
|
rules: {
|
|
142
161
|
"data/brand-meaningful-primitives": {
|
|
143
162
|
description:
|
|
144
|
-
"A primitive with semantic meaning, such as an id, email, URL, port, or count,
|
|
163
|
+
"A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
|
|
145
164
|
reference: `
|
|
146
165
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
147
166
|
type UserId = typeof UserId.Type
|
|
@@ -254,25 +273,36 @@ beats all of the above for that rule.
|
|
|
254
273
|
|
|
255
274
|
## How a file is judged
|
|
256
275
|
|
|
257
|
-
1. One Jev request per file
|
|
258
|
-
|
|
259
|
-
the
|
|
260
|
-
|
|
261
|
-
of the
|
|
276
|
+
1. One Jev request per file. The state is the file's numbered lines and
|
|
277
|
+
nothing else. Each rule is one `noul` (yes/no probability) question that
|
|
278
|
+
carries the rule: whether the file breaks the rule's description, with
|
|
279
|
+
criteria for each answer. Yes means part of the file breaks the rule, and
|
|
280
|
+
the code to avoid is an example of it. No means the file follows the rule
|
|
281
|
+
or has nothing the rule covers, and the reference is an example of it.
|
|
282
|
+
Since no rule sits in the shared state, a rule's probability depends only
|
|
283
|
+
on the file and that rule, not on which other rules share the request.
|
|
284
|
+
Every question shares the state cost of the request.
|
|
262
285
|
2. A second request only when at least one rule's probability is above its
|
|
263
286
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
264
287
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
265
288
|
lines is located in two steps (a block of 20 lines, then a line inside it).
|
|
266
|
-
|
|
289
|
+
3. Jev reads at most 32k tokens of state and one question together, and 64k
|
|
290
|
+
tokens in a request. adhere estimates tokens from the JSON it sends, at
|
|
291
|
+
about three bytes a token. When a file's questions would not fit in one
|
|
292
|
+
request, they are split across several. A file whose code and longest
|
|
293
|
+
question would not fit together, or that has more than 5100 lines, is
|
|
294
|
+
skipped and counted in the summary, and the rest of the run goes on.
|
|
267
295
|
|
|
268
296
|
## Cache
|
|
269
297
|
|
|
270
298
|
Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
|
|
271
299
|
per file. An entry stores the file's content hash and, per rule, the
|
|
272
|
-
probability, the located line, and a fingerprint of the
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
300
|
+
probability, the located line, and a fingerprint of the model and the
|
|
301
|
+
question asked for the rule, which carries the rule's text. A changed file
|
|
302
|
+
re-judges every rule for that file. An edited rule re-judges only that rule,
|
|
303
|
+
and an adhere that asks its questions differently re-judges every rule once.
|
|
304
|
+
A lowered threshold locates cached judgments that are newly above it without
|
|
305
|
+
judging again. Entries depend on content, not on the
|
|
276
306
|
machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
|
|
277
307
|
|
|
278
308
|
The API key is read only when a request is about to be sent. A run where
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against reference code in .adhere/, judged by Jev.",
|
|
6
6
|
"repository": {
|
|
@@ -67,10 +67,10 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"optionalDependencies": {
|
|
70
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
71
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
72
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
74
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
70
|
+
"@drkmttr/adhere-darwin-arm64": "0.6.0",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.6.0",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.6.0",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.6.0",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.6.0"
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Structured variants
|
|
2
|
+
description: Structured variants should be Schema.TaggedClass in a Schema.Union and matched with Match.tag and Match.exhaustive.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: An error from an external library
|
|
2
|
+
description: An error from an external library should be wrapped in a tagged error with a Schema.Defect field, not passed through raw.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations
|
|
2
|
+
description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations should be provided once at the entry point with BunServices.layer.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Each it.effect provides its own layer. it.layer
|
|
2
|
+
description: Each it.effect provides its own layer. it.layer should be used only to share an expensive resource across a suite.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: The result of a parameterized layer constructor
|
|
2
|
+
description: The result of a parameterized layer constructor should be stored in a module constant before it is used in more than one place.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Service method signatures have no requirements. Dependencies
|
|
2
|
+
description: Service method signatures have no requirements. Dependencies should be acquired inside the layer, not declared on the method.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Layers
|
|
2
|
+
description: Layers should be provided once at the program entry. A module that is not an entry point does not call Effect.provide.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A test that depends on time uses TestClock. Real sleeps and it.live
|
|
2
|
+
description: A test that depends on time uses TestClock. Real sleeps and it.live should be used only when real time is required.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,20 @@ import { AuditCacheLive } from "#services/AuditCache.ts";
|
|
|
3
3
|
import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
|
|
4
4
|
import { JevLive } from "#services/Jev.http.ts";
|
|
5
5
|
import { SourceWalkerLive } from "#services/SourceWalker.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type AuditPlan,
|
|
8
|
+
executeAudit,
|
|
9
|
+
type FileDone,
|
|
10
|
+
planAudit,
|
|
11
|
+
render,
|
|
12
|
+
} from "#workflows/audit.ts";
|
|
13
|
+
import {
|
|
14
|
+
describePlan,
|
|
15
|
+
type Progress,
|
|
16
|
+
progressAfter,
|
|
17
|
+
progressLine,
|
|
18
|
+
sendQuestion,
|
|
19
|
+
} from "#workflows/format.ts";
|
|
7
20
|
import type { Overrides } from "#config.ts";
|
|
8
21
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
9
22
|
import { initProject } from "#init.ts";
|
|
@@ -12,6 +25,7 @@ import { globalRuleSet } from "#rules.ts";
|
|
|
12
25
|
import {
|
|
13
26
|
Console,
|
|
14
27
|
Effect,
|
|
28
|
+
Exit,
|
|
15
29
|
Layer,
|
|
16
30
|
Option,
|
|
17
31
|
Path,
|
|
@@ -54,6 +68,44 @@ const threshold = Flag.float("threshold").pipe(
|
|
|
54
68
|
),
|
|
55
69
|
);
|
|
56
70
|
|
|
71
|
+
const yes = Flag.boolean("yes").pipe(
|
|
72
|
+
Flag.withAlias("y"),
|
|
73
|
+
Flag.withDefault(false),
|
|
74
|
+
Flag.withDescription(
|
|
75
|
+
"Send the requests without asking first. lint only asks with a terminal on stdin and stdout.",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
class Cancelled extends Schema.TaggedError<Cancelled>()("Cancelled", {}) {
|
|
80
|
+
readonly [Runtime.errorReported] = false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Text on stderr, beside the report on stdout. */
|
|
84
|
+
const toStderr = (text: string) =>
|
|
85
|
+
Effect.sync(() => {
|
|
86
|
+
process.stderr.write(text);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A counter on stderr, redrawn in place on a terminal as files finish. On
|
|
91
|
+
* anything else it stays quiet, so a log gets the plan and the report rather
|
|
92
|
+
* than a line per file. A failed run keeps its last count on screen.
|
|
93
|
+
*/
|
|
94
|
+
const counterFor = (plan: AuditPlan) => {
|
|
95
|
+
const live = process.stderr.isTTY === true;
|
|
96
|
+
let progress: Progress = { files: 0, requests: 0, findings: 0 };
|
|
97
|
+
const draw = () => (live ? toStderr(`\r\u001b[2K${progressLine(progress, plan)}`) : Effect.void);
|
|
98
|
+
return {
|
|
99
|
+
start: Effect.suspend(draw),
|
|
100
|
+
update: (done: FileDone) =>
|
|
101
|
+
Effect.suspend(() => {
|
|
102
|
+
progress = progressAfter(progress, done);
|
|
103
|
+
return draw();
|
|
104
|
+
}),
|
|
105
|
+
finish: (failed: boolean) => (live ? toStderr(failed ? "\n" : "\r\u001b[2K") : Effect.void),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
57
109
|
const force = Flag.boolean("force").pipe(
|
|
58
110
|
Flag.withDefault(false),
|
|
59
111
|
Flag.withDescription("Overwrite existing scaffold files."),
|
|
@@ -70,10 +122,28 @@ export const auditLayer = (overrides: Overrides) =>
|
|
|
70
122
|
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
71
123
|
|
|
72
124
|
/** `adhere lint`: the audit. */
|
|
73
|
-
export const lintCommand = Command.make("lint", { preset, threshold }, () =>
|
|
125
|
+
export const lintCommand = Command.make("lint", { preset, threshold, yes }, (input) =>
|
|
74
126
|
Effect.gen(function* () {
|
|
75
|
-
const result = yield* runAudit;
|
|
76
127
|
const stdio = yield* Stdio.Stdio;
|
|
128
|
+
const plan = yield* planAudit;
|
|
129
|
+
yield* toStderr(`${describePlan(plan).join("\n")}\n`);
|
|
130
|
+
// A prompt draws on stdout, so it needs a terminal at both ends: none when
|
|
131
|
+
// the report is redirected, or in CI.
|
|
132
|
+
const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
|
|
133
|
+
if (plan.requests > 0 && interactive && !input.yes) {
|
|
134
|
+
const send = yield* Prompt.run(
|
|
135
|
+
Prompt.confirm({ message: sendQuestion(plan), initial: true }),
|
|
136
|
+
);
|
|
137
|
+
if (!send) {
|
|
138
|
+
yield* toStderr("Nothing sent.\n");
|
|
139
|
+
return yield* Cancelled.make({});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const counter = counterFor(plan);
|
|
143
|
+
yield* counter.start;
|
|
144
|
+
const result = yield* executeAudit(plan, counter.update).pipe(
|
|
145
|
+
Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
|
|
146
|
+
);
|
|
77
147
|
const color = yield* stdio.stdoutIsTerminal;
|
|
78
148
|
const path = yield* Path.Path;
|
|
79
149
|
// One write: separate Console.log calls have interleaved out of order here.
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
|
2
2
|
import { Credentials } from "#services/Credentials.ts";
|
|
3
3
|
import {
|
|
4
4
|
blockBody,
|
|
5
|
+
type Body,
|
|
5
6
|
type ComparedRule,
|
|
6
7
|
conflictBody,
|
|
7
8
|
contradictBody,
|
|
@@ -14,10 +15,11 @@ import {
|
|
|
14
15
|
needsBlocks,
|
|
15
16
|
type Pair,
|
|
16
17
|
pairProbability,
|
|
18
|
+
requestsOf,
|
|
17
19
|
type Rules,
|
|
18
20
|
} from "#services/Jev.ts";
|
|
19
|
-
import { Effect, Layer, Record, Schedule, Schema } from "effect";
|
|
20
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
21
|
+
import { type Cause, Effect, Layer, Record, Schedule, Schema } from "effect";
|
|
22
|
+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
21
23
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
22
24
|
|
|
23
25
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
@@ -31,6 +33,27 @@ const ChoiceAnswers = Schema.Struct({
|
|
|
31
33
|
|
|
32
34
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
33
35
|
|
|
36
|
+
/** Jev's error body, on one line and cut short, or nothing when it sent none. */
|
|
37
|
+
const detailOf = (body: string): string => {
|
|
38
|
+
const text = body.trim().replace(/\s+/g, " ");
|
|
39
|
+
return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A failed request as a refusal: for an answer outside 2xx, its status and
|
|
44
|
+
* what Jev said, since the status alone does not say why; otherwise the cause.
|
|
45
|
+
*/
|
|
46
|
+
const refusalOf = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
|
|
47
|
+
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
48
|
+
return Effect.succeed(refused(`System One request failed: ${problem.message}`));
|
|
49
|
+
}
|
|
50
|
+
const { response } = problem.reason;
|
|
51
|
+
return Effect.map(
|
|
52
|
+
Effect.orElseSucceed(response.text, () => ""),
|
|
53
|
+
(body) => refused(`Jev answered HTTP ${response.status}${detailOf(body)}`),
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
34
57
|
export const JevLive = Layer.effect(Jev)(
|
|
35
58
|
Effect.gen(function* () {
|
|
36
59
|
const config = yield* AdhereConfig;
|
|
@@ -44,7 +67,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
44
67
|
}),
|
|
45
68
|
);
|
|
46
69
|
|
|
47
|
-
const ask = <A>(body:
|
|
70
|
+
const ask = <A>(body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
|
|
48
71
|
Effect.gen(function* () {
|
|
49
72
|
// Read here, not in the layer: a run with nothing pending needs no key.
|
|
50
73
|
const apiKey = yield* credentials.apiKey.pipe(
|
|
@@ -56,9 +79,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
56
79
|
).pipe(Effect.orDie);
|
|
57
80
|
const response = yield* client
|
|
58
81
|
.execute(HttpClientRequest.bearerToken(request, apiKey))
|
|
59
|
-
.pipe(
|
|
60
|
-
Effect.mapError((problem) => refused(`System One request failed: ${problem.message}`)),
|
|
61
|
-
);
|
|
82
|
+
.pipe(Effect.catch((problem) => Effect.flatMap(refusalOf(problem), Effect.fail)));
|
|
62
83
|
return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
|
|
63
84
|
Effect.mapError((problem) =>
|
|
64
85
|
refused(`System One response did not decode: ${problem.message}`),
|
|
@@ -66,8 +87,27 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
66
87
|
);
|
|
67
88
|
});
|
|
68
89
|
|
|
90
|
+
/** Every answer to a body, over as many requests as Jev's context needs. */
|
|
91
|
+
const answersTo = <A>(
|
|
92
|
+
body: Body,
|
|
93
|
+
Answers: Schema.Codec<
|
|
94
|
+
{ readonly answers: Readonly<Record<string, A>> },
|
|
95
|
+
unknown,
|
|
96
|
+
never,
|
|
97
|
+
never
|
|
98
|
+
>,
|
|
99
|
+
) =>
|
|
100
|
+
Effect.map(
|
|
101
|
+
Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
|
|
102
|
+
(responses) =>
|
|
103
|
+
responses.reduce<Record<string, A>>(
|
|
104
|
+
(answers, response) => ({ ...answers, ...response.answers }),
|
|
105
|
+
{},
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
|
|
69
109
|
const judge = Effect.fn("Jev.judge")(function* (lines: Lines, rules: Rules) {
|
|
70
|
-
const
|
|
110
|
+
const answers = yield* answersTo(judgeBody(config.model, lines, rules), NoulAnswers);
|
|
71
111
|
return Record.map(answers, (answer) => answer.noul);
|
|
72
112
|
});
|
|
73
113
|
|
|
@@ -76,17 +116,18 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
76
116
|
|
|
77
117
|
const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
|
|
78
118
|
const blocks = needsBlocks(lines)
|
|
79
|
-
? chosen(
|
|
119
|
+
? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
80
120
|
: undefined;
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
return chosen(
|
|
122
|
+
yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
123
|
+
);
|
|
83
124
|
});
|
|
84
125
|
|
|
85
126
|
const conflicts = Effect.fn("Jev.conflicts")(function* (
|
|
86
127
|
rules: ReadonlyArray<ComparedRule>,
|
|
87
128
|
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
88
129
|
) {
|
|
89
|
-
const
|
|
130
|
+
const answers = yield* answersTo(conflictBody(config.model, rules, partners), ChoiceAnswers);
|
|
90
131
|
return namedPairs(answers, partners);
|
|
91
132
|
});
|
|
92
133
|
|
package/src/services/Jev.ts
CHANGED
|
@@ -51,7 +51,26 @@ export type Pair = readonly [number, number];
|
|
|
51
51
|
/** A `choice` question accepts at most this many criteria. */
|
|
52
52
|
const CHOICE_LIMIT = 255;
|
|
53
53
|
const LINES_PER_BLOCK = 20;
|
|
54
|
-
|
|
54
|
+
const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Jev 1.13's context, from its model card: the state with its longest
|
|
58
|
+
* question, and the state with every question in the request.
|
|
59
|
+
*/
|
|
60
|
+
const QUESTION_CONTEXT = 32_000;
|
|
61
|
+
const REQUEST_CONTEXT = 64_000;
|
|
62
|
+
/** Room for what Jev wraps around a request, which `tokensOf` does not see. */
|
|
63
|
+
const WRAPPER = 1_000;
|
|
64
|
+
|
|
65
|
+
const encoder = new TextEncoder();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* An estimate, meant to run high, of the tokens Jev reads for a value: one
|
|
69
|
+
* per three bytes of its JSON. Code runs a little over three bytes a token,
|
|
70
|
+
* prose about four.
|
|
71
|
+
*/
|
|
72
|
+
export const tokensOf = (value: unknown): number =>
|
|
73
|
+
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
55
74
|
|
|
56
75
|
export const snippetOf = (line: string): string => line.trim().slice(0, 120);
|
|
57
76
|
|
|
@@ -60,18 +79,22 @@ const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
|
60
79
|
export const needsBlocks = (lines: Lines): boolean =>
|
|
61
80
|
lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
|
|
62
81
|
|
|
63
|
-
/**
|
|
82
|
+
/** The file, numbered so a line question can name a line. The only thing the questions share. */
|
|
83
|
+
const stateOf = (lines: Lines) => ({
|
|
84
|
+
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** One side of a noul: what it means, with the rule's code for that side as an example. */
|
|
88
|
+
const sideOf = (what: string, example: string | undefined) =>
|
|
89
|
+
example === undefined ? { what } : { what, examples: [example] };
|
|
90
|
+
|
|
91
|
+
/** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
|
|
64
92
|
const ruleState = ({ description, reference, avoid }: Rule) => ({
|
|
65
93
|
description,
|
|
66
94
|
...(reference === undefined ? {} : { reference }),
|
|
67
95
|
...(avoid === undefined ? {} : { avoid }),
|
|
68
96
|
});
|
|
69
97
|
|
|
70
|
-
const stateOf = (lines: Lines, rules: Rules) => ({
|
|
71
|
-
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
72
|
-
rules: Record.map(rules, (rule) => ruleState(rule)),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
98
|
/**
|
|
76
99
|
* The compared rules a question mentions, keyed by index: ids alone would
|
|
77
100
|
* collide, since a nested rule can share its id with the rule it shadows.
|
|
@@ -87,28 +110,30 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
|
|
|
87
110
|
return { rules: state };
|
|
88
111
|
};
|
|
89
112
|
|
|
90
|
-
const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
|
|
91
|
-
`state.rules["${id}"].${key}`;
|
|
92
|
-
|
|
93
113
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
114
|
+
* Whether the file breaks the rule. The rule rides in the question, not the
|
|
115
|
+
* state, so each question reads its own rule and no other. The criteria draw
|
|
116
|
+
* the line: code to avoid is a yes, and the reference is a no, as is a file
|
|
117
|
+
* with nothing the rule covers.
|
|
97
118
|
*/
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
export const judgeQuestion = (rule: Rule) => ({
|
|
120
|
+
type: "noul" as const,
|
|
121
|
+
instructions: { question: "Does `code` break `rule`?", rule: rule.description },
|
|
122
|
+
criteria: {
|
|
123
|
+
true: sideOf("Part of `code` breaks `rule`", rule.avoid),
|
|
124
|
+
false: sideOf(
|
|
125
|
+
"Every part of `code` that `rule` covers follows it, or `rule` covers no part of `code`",
|
|
126
|
+
rule.reference,
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** The rule as a line question carries it: the description, and its code on either side. */
|
|
132
|
+
const ruleFields = (rule: Rule) => ({
|
|
133
|
+
rule: rule.description,
|
|
134
|
+
...(rule.reference === undefined ? {} : { follows_rule: rule.reference }),
|
|
135
|
+
...(rule.avoid === undefined ? {} : { breaks_rule: rule.avoid }),
|
|
136
|
+
});
|
|
112
137
|
|
|
113
138
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
114
139
|
const criteria: Record<string, string> = {};
|
|
@@ -121,11 +146,8 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
121
146
|
|
|
122
147
|
export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
|
|
123
148
|
model,
|
|
124
|
-
state: stateOf(lines
|
|
125
|
-
questions: Record.map(rules, (rule
|
|
126
|
-
type: "noul" as const,
|
|
127
|
-
instructions: judgeInstructions(id, rule),
|
|
128
|
-
})),
|
|
149
|
+
state: stateOf(lines),
|
|
150
|
+
questions: Record.map(rules, (rule) => judgeQuestion(rule)),
|
|
129
151
|
});
|
|
130
152
|
|
|
131
153
|
export const locateBody = (
|
|
@@ -135,12 +157,15 @@ export const locateBody = (
|
|
|
135
157
|
blocks?: Readonly<Record<RuleId, number>>,
|
|
136
158
|
) => ({
|
|
137
159
|
model,
|
|
138
|
-
state: stateOf(lines
|
|
160
|
+
state: stateOf(lines),
|
|
139
161
|
questions: Record.map(rules, (rule, id) => {
|
|
140
162
|
const block = blocks?.[id];
|
|
141
163
|
return {
|
|
142
164
|
type: "choice" as const,
|
|
143
|
-
instructions:
|
|
165
|
+
instructions: {
|
|
166
|
+
question: "Which line of `code` most clearly breaks `rule`?",
|
|
167
|
+
...ruleFields(rule),
|
|
168
|
+
},
|
|
144
169
|
criteria:
|
|
145
170
|
block === undefined
|
|
146
171
|
? lineCriteria(lines, 1, lines.length)
|
|
@@ -159,15 +184,74 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
159
184
|
}
|
|
160
185
|
return {
|
|
161
186
|
model,
|
|
162
|
-
state: stateOf(lines
|
|
163
|
-
questions: Record.map(rules, (rule
|
|
187
|
+
state: stateOf(lines),
|
|
188
|
+
questions: Record.map(rules, (rule) => ({
|
|
164
189
|
type: "choice" as const,
|
|
165
|
-
instructions:
|
|
190
|
+
instructions: {
|
|
191
|
+
question: "Which block of `code` contains the line that most clearly breaks `rule`?",
|
|
192
|
+
...ruleFields(rule),
|
|
193
|
+
},
|
|
166
194
|
criteria,
|
|
167
195
|
})),
|
|
168
196
|
};
|
|
169
197
|
};
|
|
170
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Whether Jev can take every request a file may need: its lines within two
|
|
201
|
+
* line choices, and its code beside the longest question any rule asks.
|
|
202
|
+
*/
|
|
203
|
+
export const fits = (lines: Lines, rules: Rules): boolean => {
|
|
204
|
+
if (lines.length > MAX_LINES) return false;
|
|
205
|
+
const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
|
|
206
|
+
const questions = [
|
|
207
|
+
...Object.values(judgeBody("", lines, rules).questions),
|
|
208
|
+
...Object.values(located.questions),
|
|
209
|
+
];
|
|
210
|
+
const longest = Math.max(0, ...questions.map(tokensOf));
|
|
211
|
+
return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export interface Body {
|
|
215
|
+
readonly model: string;
|
|
216
|
+
readonly state: unknown;
|
|
217
|
+
readonly questions: Readonly<Record<string, unknown>>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A body as requests Jev can take: its questions in order, with a new
|
|
222
|
+
* request whenever the next question would not fit beside the state and
|
|
223
|
+
* the questions before it.
|
|
224
|
+
*/
|
|
225
|
+
export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
226
|
+
const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
|
|
227
|
+
const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
|
|
228
|
+
for (const [id, question] of Object.entries(body.questions)) {
|
|
229
|
+
const tokens = tokensOf(question);
|
|
230
|
+
const last = requests.at(-1);
|
|
231
|
+
if (last !== undefined && last.tokens + tokens <= room) {
|
|
232
|
+
last.questions[id] = question;
|
|
233
|
+
last.tokens += tokens;
|
|
234
|
+
} else {
|
|
235
|
+
requests.push({ questions: { [id]: question }, tokens });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
242
|
+
export const judgeRequests = (lines: Lines, rules: Rules): number =>
|
|
243
|
+
requestsOf(judgeBody("", lines, rules)).length;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The requests locating `rules` in a file takes, split to fit as `locate`
|
|
247
|
+
* sends them: a block choice first for a long file, then a line choice. For a
|
|
248
|
+
* long file the line choice is counted before a block is chosen, so it can
|
|
249
|
+
* run high.
|
|
250
|
+
*/
|
|
251
|
+
export const locateRequests = (lines: Lines, rules: Rules): number =>
|
|
252
|
+
(needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
|
|
253
|
+
requestsOf(locateBody("", lines, rules)).length;
|
|
254
|
+
|
|
171
255
|
/** A choice keeps one criterion for "none", which leaves this many for partners. */
|
|
172
256
|
const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
|
|
173
257
|
|
package/src/workflows/audit.ts
CHANGED
|
@@ -2,7 +2,15 @@ import type { Rule, RuleId } from "#config.ts";
|
|
|
2
2
|
import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
3
3
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
4
4
|
import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fits,
|
|
7
|
+
Jev,
|
|
8
|
+
JevUnavailable,
|
|
9
|
+
judgeQuestion,
|
|
10
|
+
judgeRequests,
|
|
11
|
+
locateRequests,
|
|
12
|
+
snippetOf,
|
|
13
|
+
} from "#services/Jev.ts";
|
|
6
14
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
7
15
|
import { applicableRules } from "#rules.ts";
|
|
8
16
|
import { type Crypto, Effect, Record } from "effect";
|
|
@@ -45,47 +53,90 @@ const fileResult = (
|
|
|
45
53
|
const isEmpty = Record.isEmptyReadonlyRecord;
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
|
-
* What a judgment depends on besides the file
|
|
49
|
-
*
|
|
50
|
-
*
|
|
56
|
+
* What a judgment depends on besides the file: the model, and the question
|
|
57
|
+
* asked for the rule, which carries the rule. An edited rule re-judges
|
|
58
|
+
* itself, and a question asked differently re-judges every rule.
|
|
51
59
|
*/
|
|
52
60
|
const fingerprintOf = (model: string, rule: Rule) =>
|
|
53
|
-
sha256(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
sha256(`${model}\u0000${JSON.stringify(judgeQuestion(rule))}`);
|
|
62
|
+
|
|
63
|
+
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
64
|
+
export interface FilePlan {
|
|
65
|
+
readonly file: ScannedFile;
|
|
66
|
+
/** Too long for Jev: counted, not judged. */
|
|
67
|
+
readonly skipped: boolean;
|
|
68
|
+
readonly hash: string;
|
|
69
|
+
readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
|
|
70
|
+
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
71
|
+
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
72
|
+
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
76
|
+
export interface AuditPlan {
|
|
77
|
+
readonly files: ReadonlyArray<FilePlan>;
|
|
78
|
+
/** Rules that apply to at least one file that is judged. */
|
|
79
|
+
readonly rules: number;
|
|
80
|
+
/** Every pair of a judged file and a rule that applies to it. */
|
|
81
|
+
readonly checks: number;
|
|
82
|
+
/** Checks a cached judgment answers. */
|
|
83
|
+
readonly cached: number;
|
|
84
|
+
/** Files too long to judge. */
|
|
85
|
+
readonly skipped: number;
|
|
86
|
+
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
87
|
+
readonly requests: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A file finished, for a progress counter: the requests it took and the findings it has. */
|
|
91
|
+
export interface FileDone {
|
|
92
|
+
readonly requests: number;
|
|
93
|
+
readonly findings: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
|
|
97
|
+
|
|
98
|
+
const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
|
|
99
|
+
|
|
100
|
+
/** A refusal that names the file it stopped at. Files finished before it are already cached. */
|
|
101
|
+
const inFile = (file: ScannedFile) =>
|
|
102
|
+
Effect.mapError((problem: JevUnavailable) =>
|
|
103
|
+
JevUnavailable.make({
|
|
104
|
+
message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
|
|
105
|
+
}),
|
|
58
106
|
);
|
|
59
107
|
|
|
60
|
-
export const
|
|
61
|
-
|
|
62
|
-
WalkUnavailable
|
|
63
|
-
AdhereConfig | AuditCache | Crypto.Crypto |
|
|
108
|
+
export const planAudit: Effect.Effect<
|
|
109
|
+
AuditPlan,
|
|
110
|
+
WalkUnavailable,
|
|
111
|
+
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
64
112
|
> = Effect.gen(function* () {
|
|
65
113
|
const config = yield* AdhereConfig;
|
|
66
|
-
const jev = yield* Jev;
|
|
67
114
|
const cache = yield* AuditCache;
|
|
68
115
|
const files = yield* (yield* SourceWalker).files;
|
|
69
116
|
|
|
70
117
|
const configuredRules = (file: string) =>
|
|
71
|
-
config.scopedRules === undefined
|
|
72
|
-
? config.rules
|
|
73
|
-
: applicableRules(file, config.scopedRules);
|
|
118
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
74
119
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (file.lines
|
|
120
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
121
|
+
const rules = configuredRules(file.path);
|
|
122
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
123
|
+
if (!fits(file.lines, rules)) {
|
|
124
|
+
return {
|
|
125
|
+
file,
|
|
126
|
+
skipped: true,
|
|
127
|
+
hash: "",
|
|
128
|
+
prepared: {},
|
|
129
|
+
kept: {},
|
|
130
|
+
pending: {},
|
|
131
|
+
} satisfies FilePlan;
|
|
132
|
+
}
|
|
79
133
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
80
|
-
Object.entries(
|
|
134
|
+
Object.entries(rules),
|
|
81
135
|
([id, rule]) =>
|
|
82
136
|
Effect.map(
|
|
83
137
|
fingerprintOf(config.model, rule),
|
|
84
138
|
(fingerprint) =>
|
|
85
|
-
[
|
|
86
|
-
id,
|
|
87
|
-
{ rule, fingerprint, threshold: rule.threshold ?? config.threshold },
|
|
88
|
-
] as const,
|
|
139
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
89
140
|
),
|
|
90
141
|
).pipe(Effect.map(Record.fromEntries));
|
|
91
142
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
@@ -96,64 +147,129 @@ export const runAudit: Effect.Effect<
|
|
|
96
147
|
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
97
148
|
);
|
|
98
149
|
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
99
|
-
|
|
100
|
-
const probabilities = isEmpty(pending) ? {} : yield* jev.judge(file.lines, rulesOf(pending));
|
|
101
|
-
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
102
|
-
for (const [id, probability] of Object.entries(probabilities)) {
|
|
103
|
-
const k = pending[id];
|
|
104
|
-
if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const flagged = Record.filter(prepared, (k, id) => {
|
|
108
|
-
const judgment = judged[id];
|
|
109
|
-
return (
|
|
110
|
-
judgment !== undefined && judgment.probability > k.threshold && judgment.line === undefined
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
const lines = isEmpty(flagged) ? {} : yield* jev.locate(file.lines, rulesOf(flagged));
|
|
114
|
-
const located = Record.map(judged, (judgment, id) => {
|
|
115
|
-
const line = lines[id];
|
|
116
|
-
return line === undefined
|
|
117
|
-
? judgment
|
|
118
|
-
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
122
|
-
if (!cached) yield* cache.put(file.path, { hash, judgments: located });
|
|
123
|
-
|
|
124
|
-
const findings = Object.entries(located)
|
|
125
|
-
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
126
|
-
const k = prepared[id];
|
|
127
|
-
return k !== undefined && judgment.line !== undefined && judgment.probability > k.threshold
|
|
128
|
-
? [
|
|
129
|
-
{
|
|
130
|
-
rule: id,
|
|
131
|
-
description: k.rule.description,
|
|
132
|
-
reference: k.rule.reference,
|
|
133
|
-
avoid: k.rule.avoid,
|
|
134
|
-
file: file.path,
|
|
135
|
-
line: judgment.line,
|
|
136
|
-
snippet: judgment.snippet ?? "",
|
|
137
|
-
probability: judgment.probability,
|
|
138
|
-
},
|
|
139
|
-
]
|
|
140
|
-
: [];
|
|
141
|
-
})
|
|
142
|
-
.sort((a, b) => a.line - b.line);
|
|
143
|
-
return fileResult(cached ? "cached" : "judged", findings);
|
|
150
|
+
return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
|
|
144
151
|
});
|
|
145
152
|
|
|
146
153
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
155
|
+
concurrency: 8,
|
|
156
|
+
});
|
|
157
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
158
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
159
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
150
160
|
return {
|
|
151
|
-
files:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
files: planned,
|
|
162
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
163
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
164
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
165
|
+
skipped: planned.length - judged.length,
|
|
166
|
+
requests: total((plan) =>
|
|
167
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
168
|
+
),
|
|
156
169
|
};
|
|
157
170
|
});
|
|
158
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Judges what the plan left pending and locates the findings, a file at a
|
|
174
|
+
* time, telling `progress` as each file finishes.
|
|
175
|
+
*/
|
|
176
|
+
export const executeAudit = (
|
|
177
|
+
plan: AuditPlan,
|
|
178
|
+
progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
|
|
179
|
+
): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
const jev = yield* Jev;
|
|
182
|
+
const cache = yield* AuditCache;
|
|
183
|
+
|
|
184
|
+
const auditFile = Effect.fn("audit.file")(function* ({
|
|
185
|
+
file,
|
|
186
|
+
skipped,
|
|
187
|
+
hash,
|
|
188
|
+
prepared,
|
|
189
|
+
kept,
|
|
190
|
+
pending,
|
|
191
|
+
}: FilePlan) {
|
|
192
|
+
if (skipped) {
|
|
193
|
+
yield* progress({ requests: 0, findings: 0 });
|
|
194
|
+
return fileResult("skipped", []);
|
|
195
|
+
}
|
|
196
|
+
const probabilities = isEmpty(pending)
|
|
197
|
+
? {}
|
|
198
|
+
: yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
|
|
199
|
+
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
200
|
+
for (const [id, probability] of Object.entries(probabilities)) {
|
|
201
|
+
const k = pending[id];
|
|
202
|
+
if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const flagged = Record.filter(prepared, (k, id) => {
|
|
206
|
+
const judgment = judged[id];
|
|
207
|
+
return (
|
|
208
|
+
judgment !== undefined &&
|
|
209
|
+
judgment.probability > k.threshold &&
|
|
210
|
+
judgment.line === undefined
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
const lines = isEmpty(flagged)
|
|
214
|
+
? {}
|
|
215
|
+
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(inFile(file));
|
|
216
|
+
const located = Record.map(judged, (judgment, id) => {
|
|
217
|
+
const line = lines[id];
|
|
218
|
+
return line === undefined
|
|
219
|
+
? judgment
|
|
220
|
+
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
224
|
+
if (!cached) yield* cache.put(file.path, { hash, judgments: located });
|
|
225
|
+
|
|
226
|
+
const findings = Object.entries(located)
|
|
227
|
+
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
228
|
+
const k = prepared[id];
|
|
229
|
+
return k !== undefined &&
|
|
230
|
+
judgment.line !== undefined &&
|
|
231
|
+
judgment.probability > k.threshold
|
|
232
|
+
? [
|
|
233
|
+
{
|
|
234
|
+
rule: id,
|
|
235
|
+
description: k.rule.description,
|
|
236
|
+
reference: k.rule.reference,
|
|
237
|
+
avoid: k.rule.avoid,
|
|
238
|
+
file: file.path,
|
|
239
|
+
line: judgment.line,
|
|
240
|
+
snippet: judgment.snippet ?? "",
|
|
241
|
+
probability: judgment.probability,
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
: [];
|
|
245
|
+
})
|
|
246
|
+
.sort((a, b) => a.line - b.line);
|
|
247
|
+
yield* progress({
|
|
248
|
+
requests:
|
|
249
|
+
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
250
|
+
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
251
|
+
findings: findings.length,
|
|
252
|
+
});
|
|
253
|
+
return fileResult(cached ? "cached" : "judged", findings);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
257
|
+
const count = (status: FileResult["status"]) =>
|
|
258
|
+
results.filter((result) => result.status === status).length;
|
|
259
|
+
return {
|
|
260
|
+
files: plan.files.length,
|
|
261
|
+
judged: count("judged"),
|
|
262
|
+
cached: count("cached"),
|
|
263
|
+
skipped: count("skipped"),
|
|
264
|
+
findings: results.flatMap((result) => result.findings),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/** Plans the run and executes it, without asking and without a counter. */
|
|
269
|
+
export const runAudit: Effect.Effect<
|
|
270
|
+
AuditResult,
|
|
271
|
+
WalkUnavailable | JevUnavailable,
|
|
272
|
+
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
273
|
+
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
274
|
+
|
|
159
275
|
export { type RenderOptions, render } from "#workflows/format.ts";
|
package/src/workflows/format.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Kind, tokenize } from "#highlight.ts";
|
|
2
|
-
import type { AuditResult, Finding } from "#workflows/audit.ts";
|
|
2
|
+
import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The report as data: lines of spans, each span a part of the frame or a
|
|
@@ -143,3 +143,44 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
143
143
|
...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
|
|
144
144
|
...summary(result),
|
|
145
145
|
].map((line) => serialize(line, options.color === true));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a run will do, before it asks: the files and rules found, the checks
|
|
149
|
+
* between them and how many the cache answers, then what judging the rest
|
|
150
|
+
* takes.
|
|
151
|
+
*/
|
|
152
|
+
export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
|
|
153
|
+
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
154
|
+
const skipped =
|
|
155
|
+
plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
|
|
156
|
+
const found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
|
|
157
|
+
if (plan.requests === 0) {
|
|
158
|
+
return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
|
|
159
|
+
}
|
|
160
|
+
const rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
|
|
161
|
+
return [
|
|
162
|
+
found,
|
|
163
|
+
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.`,
|
|
164
|
+
];
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The question before a run sends anything. */
|
|
168
|
+
export const sendQuestion = (plan: AuditPlan): string =>
|
|
169
|
+
`Send ${counted(plan.requests, "request", "requests")} to Jev?`;
|
|
170
|
+
|
|
171
|
+
/** A run's progress so far: every file it finished, with the requests and findings they added up to. */
|
|
172
|
+
export interface Progress {
|
|
173
|
+
readonly files: number;
|
|
174
|
+
readonly requests: number;
|
|
175
|
+
readonly findings: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const progressAfter = (progress: Progress, done: FileDone): Progress => ({
|
|
179
|
+
files: progress.files + 1,
|
|
180
|
+
requests: progress.requests + done.requests,
|
|
181
|
+
findings: progress.findings + done.findings,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** The counter a run redraws as files finish. */
|
|
185
|
+
export const progressLine = (progress: Progress, plan: AuditPlan): string =>
|
|
186
|
+
`${progress.files}/${plan.files.length} files, ${counted(progress.requests, "request", "requests")} sent, ${counted(progress.findings, "finding", "findings")}`;
|