@drkmttr/adhere 0.5.0 → 0.6.1
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 +55 -17
- 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 +79 -13
- package/src/services/Jev.ts +144 -35
- package/src/workflows/audit.ts +210 -79
- package/src/workflows/format.ts +42 -1
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# adhere
|
|
2
2
|
|
|
3
3
|
A linter for rules a normal linter cannot check. A rule is a one-sentence
|
|
4
|
-
description
|
|
4
|
+
description like "business logic should live in services", plus correct
|
|
5
|
+
reference code / code to avoid / or both. For each
|
|
5
6
|
source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
7
|
model) whether the file breaks each rule, gets a calibrated probability per
|
|
7
8
|
rule, and reports the ones above a threshold with the line Jev points at. The
|
|
@@ -9,18 +10,23 @@ report uses the same frame as `vp lint`.
|
|
|
9
10
|
|
|
10
11
|
Deterministic rules (substring matches, type checks) belong in a normal linter.
|
|
11
12
|
|
|
13
|
+
A preset for effect is included which can be run without setup:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
|
|
17
|
+
```
|
|
18
|
+
|
|
12
19
|
## Install
|
|
13
20
|
|
|
14
21
|
```sh
|
|
15
|
-
|
|
22
|
+
bun add --global @drkmttr/adhere # or: npm install --global @drkmttr/adhere
|
|
16
23
|
```
|
|
17
24
|
|
|
18
25
|
To pin the version in a repo, for CI or scripts, add it as a dev dependency
|
|
19
|
-
and run `npx adhere`.
|
|
20
|
-
`npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
|
|
26
|
+
and run `npx adhere`.
|
|
21
27
|
|
|
22
28
|
`adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
|
|
23
|
-
Windows on x64.
|
|
29
|
+
Windows on x64. .
|
|
24
30
|
|
|
25
31
|
adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
|
|
26
32
|
with a TypeSafe AI API key: the one `adhere login` saved, or
|
|
@@ -32,7 +38,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
|
|
|
32
38
|
adhere init # .adhere/config.ts and two example rules
|
|
33
39
|
echo ".adhere/cache/" >> .gitignore
|
|
34
40
|
adhere login # save your TypeSafe AI API key, once
|
|
35
|
-
adhere validate #
|
|
41
|
+
adhere validate # Checks your rules for contradictions using Jev
|
|
36
42
|
adhere lint # audit the working directory
|
|
37
43
|
```
|
|
38
44
|
|
|
@@ -68,6 +74,7 @@ refusal prints its reason.
|
|
|
68
74
|
adhere lint # audit the working directory
|
|
69
75
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
70
76
|
adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
|
|
77
|
+
adhere lint --yes # send the requests without asking first
|
|
71
78
|
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
72
79
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
73
80
|
adhere login # save a TypeSafe AI API key for later runs
|
|
@@ -79,6 +86,24 @@ Bare `adhere` prints the help, which lists the commands, and
|
|
|
79
86
|
`adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
|
|
80
87
|
prints a completion script.
|
|
81
88
|
|
|
89
|
+
### Before and during a run
|
|
90
|
+
|
|
91
|
+
Before it sends anything, `lint` says on stderr what it found and what judging
|
|
92
|
+
takes:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
200 files and 14 rules: 2800 checks, 1400 cached.
|
|
96
|
+
Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
|
|
97
|
+
? Send 200 requests to Jev? › (Y/n)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
It asks only with a terminal on stdin and stdout, and never when the cache
|
|
101
|
+
answers every check; `--yes` sends without asking. While it runs, a counter on
|
|
102
|
+
stderr shows the files done, the requests sent, and the findings so far, and
|
|
103
|
+
the report goes to stdout. A request Jev refuses stops the run with the file,
|
|
104
|
+
the HTTP status, and Jev's reason. Files finished before it are cached, so
|
|
105
|
+
running again continues from there.
|
|
106
|
+
|
|
82
107
|
### What gets read
|
|
83
108
|
|
|
84
109
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
|
@@ -141,7 +166,7 @@ export default {
|
|
|
141
166
|
rules: {
|
|
142
167
|
"data/brand-meaningful-primitives": {
|
|
143
168
|
description:
|
|
144
|
-
"A primitive with semantic meaning, such as an id, email, URL, port, or count,
|
|
169
|
+
"A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
|
|
145
170
|
reference: `
|
|
146
171
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
147
172
|
type UserId = typeof UserId.Type
|
|
@@ -254,25 +279,38 @@ beats all of the above for that rule.
|
|
|
254
279
|
|
|
255
280
|
## How a file is judged
|
|
256
281
|
|
|
257
|
-
1. One Jev request per file
|
|
258
|
-
|
|
259
|
-
the
|
|
260
|
-
|
|
261
|
-
|
|
282
|
+
1. One Jev request per file. The state is the file's numbered lines and
|
|
283
|
+
nothing else. Each rule is one `noul` (yes/no probability) question that
|
|
284
|
+
carries the rule's description, reference, and code to avoid: whether the
|
|
285
|
+
file diverges from the reference or, for a rule with only code to avoid,
|
|
286
|
+
whether it contains that code. Criteria for each answer draw the line at
|
|
287
|
+
the rule's scope, so a file with no code the rule is about is a no. Since
|
|
288
|
+
no rule sits in the shared state, a rule's probability depends only on the
|
|
289
|
+
file and that rule, not on which other rules share the request. Every
|
|
290
|
+
question shares the state cost of the request.
|
|
262
291
|
2. A second request only when at least one rule's probability is above its
|
|
263
292
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
264
293
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
265
294
|
lines is located in two steps (a block of 20 lines, then a line inside it).
|
|
266
|
-
|
|
295
|
+
3. Jev reads at most 32k tokens of state and one question together, and 64k
|
|
296
|
+
tokens in a request. adhere estimates tokens from the JSON it sends, at
|
|
297
|
+
about three bytes a token. When a file's questions would not fit in one
|
|
298
|
+
request, they are split across several. A file whose code and longest
|
|
299
|
+
question would not fit together, or that has more than 5100 lines, is
|
|
300
|
+
skipped and counted in the summary, and the rest of the run goes on. So is
|
|
301
|
+
a file Jev itself counts as over its context, which text denser than the
|
|
302
|
+
estimate, such as CJK, can cause.
|
|
267
303
|
|
|
268
304
|
## Cache
|
|
269
305
|
|
|
270
306
|
Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
|
|
271
307
|
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
|
-
|
|
308
|
+
probability, the located line, and a fingerprint of the model and the
|
|
309
|
+
question asked for the rule, which carries the rule's text. A changed file
|
|
310
|
+
re-judges every rule for that file. An edited rule re-judges only that rule,
|
|
311
|
+
and an adhere that asks its questions differently re-judges every rule once.
|
|
312
|
+
A lowered threshold locates cached judgments that are newly above it without
|
|
313
|
+
judging again. Entries depend on content, not on the
|
|
276
314
|
machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
|
|
277
315
|
|
|
278
316
|
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.1",
|
|
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.1",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.6.1",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.6.1",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.6.1",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.6.1"
|
|
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,10 +2,12 @@ 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,
|
|
8
9
|
Jev,
|
|
10
|
+
JevOverflow,
|
|
9
11
|
JevUnavailable,
|
|
10
12
|
judgeBody,
|
|
11
13
|
type Lines,
|
|
@@ -14,10 +16,11 @@ import {
|
|
|
14
16
|
needsBlocks,
|
|
15
17
|
type Pair,
|
|
16
18
|
pairProbability,
|
|
19
|
+
requestsOf,
|
|
17
20
|
type Rules,
|
|
18
21
|
} from "#services/Jev.ts";
|
|
19
|
-
import { Effect, Layer, Record, Schedule, Schema } from "effect";
|
|
20
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
22
|
+
import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
|
+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
21
24
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
22
25
|
|
|
23
26
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
@@ -31,6 +34,47 @@ const ChoiceAnswers = Schema.Struct({
|
|
|
31
34
|
|
|
32
35
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
33
36
|
|
|
37
|
+
/** Jev's error body, on one line and cut short, or nothing when it sent none. */
|
|
38
|
+
const detailOf = (body: string): string => {
|
|
39
|
+
const text = body.trim().replace(/\s+/g, " ");
|
|
40
|
+
return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What Jev answers, with a 400, to a request over its context. */
|
|
44
|
+
const Overflowed = Schema.fromJsonString(
|
|
45
|
+
Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
|
|
46
|
+
);
|
|
47
|
+
const isOverflow = (body: string): boolean =>
|
|
48
|
+
Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A request that failed. A 400 saying the request is over Jev's context is a
|
|
52
|
+
* `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
|
|
53
|
+
* the run with its status and what Jev said, since the status alone does not
|
|
54
|
+
* say why, and a request that got no answer refuses with the cause.
|
|
55
|
+
*/
|
|
56
|
+
const failed = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
|
|
57
|
+
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
58
|
+
return Effect.fail(refused(`System One request failed: ${problem.message}`));
|
|
59
|
+
}
|
|
60
|
+
const { response } = problem.reason;
|
|
61
|
+
return Effect.flatMap(
|
|
62
|
+
Effect.orElseSucceed(response.text, () => ""),
|
|
63
|
+
(body): Effect.Effect<never, JevOverflow | JevUnavailable> =>
|
|
64
|
+
response.status === 400 && isOverflow(body)
|
|
65
|
+
? Effect.fail(JevOverflow.make())
|
|
66
|
+
: Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Comparing rules has no file to skip, so a request over Jev's context refuses instead. */
|
|
71
|
+
const refuseOverflow = <A>(
|
|
72
|
+
self: Effect.Effect<A, JevOverflow | JevUnavailable>,
|
|
73
|
+
): Effect.Effect<A, JevUnavailable> =>
|
|
74
|
+
Effect.catchTag(self, "JevOverflow", () =>
|
|
75
|
+
Effect.fail(refused("Comparing the rules took a request over Jev's context")),
|
|
76
|
+
);
|
|
77
|
+
|
|
34
78
|
export const JevLive = Layer.effect(Jev)(
|
|
35
79
|
Effect.gen(function* () {
|
|
36
80
|
const config = yield* AdhereConfig;
|
|
@@ -44,7 +88,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
44
88
|
}),
|
|
45
89
|
);
|
|
46
90
|
|
|
47
|
-
const ask = <A>(body:
|
|
91
|
+
const ask = <A>(body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
|
|
48
92
|
Effect.gen(function* () {
|
|
49
93
|
// Read here, not in the layer: a run with nothing pending needs no key.
|
|
50
94
|
const apiKey = yield* credentials.apiKey.pipe(
|
|
@@ -56,9 +100,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
56
100
|
).pipe(Effect.orDie);
|
|
57
101
|
const response = yield* client
|
|
58
102
|
.execute(HttpClientRequest.bearerToken(request, apiKey))
|
|
59
|
-
.pipe(
|
|
60
|
-
Effect.mapError((problem) => refused(`System One request failed: ${problem.message}`)),
|
|
61
|
-
);
|
|
103
|
+
.pipe(Effect.catch(failed));
|
|
62
104
|
return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
|
|
63
105
|
Effect.mapError((problem) =>
|
|
64
106
|
refused(`System One response did not decode: ${problem.message}`),
|
|
@@ -66,8 +108,27 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
66
108
|
);
|
|
67
109
|
});
|
|
68
110
|
|
|
111
|
+
/** Every answer to a body, over as many requests as Jev's context needs. */
|
|
112
|
+
const answersTo = <A>(
|
|
113
|
+
body: Body,
|
|
114
|
+
Answers: Schema.Codec<
|
|
115
|
+
{ readonly answers: Readonly<Record<string, A>> },
|
|
116
|
+
unknown,
|
|
117
|
+
never,
|
|
118
|
+
never
|
|
119
|
+
>,
|
|
120
|
+
) =>
|
|
121
|
+
Effect.map(
|
|
122
|
+
Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
|
|
123
|
+
(responses) =>
|
|
124
|
+
responses.reduce<Record<string, A>>(
|
|
125
|
+
(answers, response) => ({ ...answers, ...response.answers }),
|
|
126
|
+
{},
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
69
130
|
const judge = Effect.fn("Jev.judge")(function* (lines: Lines, rules: Rules) {
|
|
70
|
-
const
|
|
131
|
+
const answers = yield* answersTo(judgeBody(config.model, lines, rules), NoulAnswers);
|
|
71
132
|
return Record.map(answers, (answer) => answer.noul);
|
|
72
133
|
});
|
|
73
134
|
|
|
@@ -76,17 +137,21 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
76
137
|
|
|
77
138
|
const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
|
|
78
139
|
const blocks = needsBlocks(lines)
|
|
79
|
-
? chosen(
|
|
140
|
+
? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
80
141
|
: undefined;
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
return chosen(
|
|
143
|
+
yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
144
|
+
);
|
|
83
145
|
});
|
|
84
146
|
|
|
85
147
|
const conflicts = Effect.fn("Jev.conflicts")(function* (
|
|
86
148
|
rules: ReadonlyArray<ComparedRule>,
|
|
87
149
|
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
88
150
|
) {
|
|
89
|
-
const
|
|
151
|
+
const answers = yield* answersTo(
|
|
152
|
+
conflictBody(config.model, rules, partners),
|
|
153
|
+
ChoiceAnswers,
|
|
154
|
+
).pipe(refuseOverflow);
|
|
90
155
|
return namedPairs(answers, partners);
|
|
91
156
|
});
|
|
92
157
|
|
|
@@ -97,8 +162,9 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
97
162
|
return yield* Effect.forEach(
|
|
98
163
|
pairs,
|
|
99
164
|
(pair) =>
|
|
100
|
-
Effect.map(
|
|
101
|
-
|
|
165
|
+
Effect.map(
|
|
166
|
+
ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(refuseOverflow),
|
|
167
|
+
({ answers }) => pairProbability(answers, pair),
|
|
102
168
|
),
|
|
103
169
|
{ concurrency: 8 },
|
|
104
170
|
);
|
package/src/services/Jev.ts
CHANGED
|
@@ -8,17 +8,23 @@ export class JevUnavailable extends Schema.TaggedError<JevUnavailable>()("JevUna
|
|
|
8
8
|
message: Schema.String,
|
|
9
9
|
}) {}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Jev's refusal of a request over its context. `fits` keeps such files from
|
|
13
|
+
* being sent, but by an estimate; this is Jev's own count disagreeing.
|
|
14
|
+
*/
|
|
15
|
+
export class JevOverflow extends Schema.TaggedError<JevOverflow>()("JevOverflow", {}) {}
|
|
16
|
+
|
|
11
17
|
export class Jev extends Context.Service<
|
|
12
18
|
Jev,
|
|
13
19
|
{
|
|
14
20
|
readonly judge: (
|
|
15
21
|
lines: Lines,
|
|
16
22
|
rules: Rules,
|
|
17
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
|
|
23
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
18
24
|
readonly locate: (
|
|
19
25
|
lines: Lines,
|
|
20
26
|
rules: Rules,
|
|
21
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
|
|
27
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
22
28
|
/**
|
|
23
29
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
24
30
|
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
@@ -51,7 +57,27 @@ export type Pair = readonly [number, number];
|
|
|
51
57
|
/** A `choice` question accepts at most this many criteria. */
|
|
52
58
|
const CHOICE_LIMIT = 255;
|
|
53
59
|
const LINES_PER_BLOCK = 20;
|
|
54
|
-
|
|
60
|
+
const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Jev 1.13's context, from its model card: the state with its longest
|
|
64
|
+
* question, and the state with every question in the request.
|
|
65
|
+
*/
|
|
66
|
+
const QUESTION_CONTEXT = 32_000;
|
|
67
|
+
const REQUEST_CONTEXT = 64_000;
|
|
68
|
+
/** Room for what Jev wraps around a request, which `tokensOf` does not see. */
|
|
69
|
+
const WRAPPER = 1_000;
|
|
70
|
+
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An estimate, meant to run high, of the tokens Jev reads for a value: one
|
|
75
|
+
* per three bytes of its JSON. Jev 1.13 reads numbered TypeScript at 3.2
|
|
76
|
+
* bytes a token, and this ran 12 to 16 percent high on the eval's requests.
|
|
77
|
+
* Text that packs more tokens into its bytes, such as CJK, can outrun it.
|
|
78
|
+
*/
|
|
79
|
+
export const tokensOf = (value: unknown): number =>
|
|
80
|
+
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
55
81
|
|
|
56
82
|
export const snippetOf = (line: string): string => line.trim().slice(0, 120);
|
|
57
83
|
|
|
@@ -60,18 +86,25 @@ const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
|
60
86
|
export const needsBlocks = (lines: Lines): boolean =>
|
|
61
87
|
lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
|
|
62
88
|
|
|
63
|
-
/**
|
|
89
|
+
/** The file, numbered so a line question can name a line. The only thing the questions share. */
|
|
90
|
+
const stateOf = (lines: Lines) => ({
|
|
91
|
+
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The rule as its questions carry it: the description, and its code on either side. */
|
|
95
|
+
const ruleFields = (rule: Rule) => ({
|
|
96
|
+
rule: rule.description,
|
|
97
|
+
...(rule.reference === undefined ? {} : { reference: rule.reference }),
|
|
98
|
+
...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
|
|
64
102
|
const ruleState = ({ description, reference, avoid }: Rule) => ({
|
|
65
103
|
description,
|
|
66
104
|
...(reference === undefined ? {} : { reference }),
|
|
67
105
|
...(avoid === undefined ? {} : { avoid }),
|
|
68
106
|
});
|
|
69
107
|
|
|
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
108
|
/**
|
|
76
109
|
* The compared rules a question mentions, keyed by index: ids alone would
|
|
77
110
|
* collide, since a nested rule can share its id with the rule it shadows.
|
|
@@ -87,28 +120,45 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
|
|
|
87
120
|
return { rules: state };
|
|
88
121
|
};
|
|
89
122
|
|
|
90
|
-
const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
|
|
91
|
-
`state.rules["${id}"].${key}`;
|
|
92
|
-
|
|
93
123
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* whether the file
|
|
124
|
+
* Whether the file breaks the rule. The rule rides in the question, not the
|
|
125
|
+
* state, so each question reads its own rule and no other. A rule with a
|
|
126
|
+
* reference asks whether the file diverges from it, with the code to avoid
|
|
127
|
+
* as an example when there is some. A rule without one asks whether the file
|
|
128
|
+
* contains the code to avoid. The criteria draw the line at the rule's scope:
|
|
129
|
+
* a file with no code the rule is about is a no.
|
|
97
130
|
*/
|
|
98
|
-
const
|
|
99
|
-
rule.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
export const judgeQuestion = (rule: Rule) => {
|
|
132
|
+
const example = rule.avoid === undefined ? "" : ", for example by doing what `avoid` shows";
|
|
133
|
+
const [question, yes, no] =
|
|
134
|
+
rule.reference === undefined
|
|
135
|
+
? [
|
|
136
|
+
"Does `code` contain the pattern shown in `avoid`, which `rule` rules out? Answer no if nothing in this file resembles it.",
|
|
137
|
+
"`code` contains the pattern shown in `avoid`",
|
|
138
|
+
"`code` has nothing like `avoid`",
|
|
139
|
+
]
|
|
140
|
+
: [
|
|
141
|
+
`Does \`code\` diverge from the pattern shown in \`reference\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
|
|
142
|
+
`\`code\` diverges from the pattern shown in \`reference\`${example}`,
|
|
143
|
+
"`code` follows the pattern",
|
|
144
|
+
];
|
|
145
|
+
return {
|
|
146
|
+
type: "noul" as const,
|
|
147
|
+
instructions: { question, ...ruleFields(rule) },
|
|
148
|
+
criteria: {
|
|
149
|
+
true: `${yes}, in code that \`rule\` is about`,
|
|
150
|
+
false: `${no}, or has no code that \`rule\` is about`,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
104
154
|
|
|
105
155
|
/** What the offending line does, for the line and block questions. */
|
|
106
|
-
const offense = (
|
|
156
|
+
const offense = (rule: Rule): string =>
|
|
107
157
|
rule.reference === undefined
|
|
108
|
-
?
|
|
158
|
+
? "shows the pattern in `avoid`"
|
|
109
159
|
: rule.avoid === undefined
|
|
110
|
-
?
|
|
111
|
-
:
|
|
160
|
+
? "diverges from `reference`"
|
|
161
|
+
: "diverges from `reference` or resembles `avoid`";
|
|
112
162
|
|
|
113
163
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
114
164
|
const criteria: Record<string, string> = {};
|
|
@@ -121,11 +171,8 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
121
171
|
|
|
122
172
|
export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
|
|
123
173
|
model,
|
|
124
|
-
state: stateOf(lines
|
|
125
|
-
questions: Record.map(rules, (rule
|
|
126
|
-
type: "noul" as const,
|
|
127
|
-
instructions: judgeInstructions(id, rule),
|
|
128
|
-
})),
|
|
174
|
+
state: stateOf(lines),
|
|
175
|
+
questions: Record.map(rules, (rule) => judgeQuestion(rule)),
|
|
129
176
|
});
|
|
130
177
|
|
|
131
178
|
export const locateBody = (
|
|
@@ -135,12 +182,15 @@ export const locateBody = (
|
|
|
135
182
|
blocks?: Readonly<Record<RuleId, number>>,
|
|
136
183
|
) => ({
|
|
137
184
|
model,
|
|
138
|
-
state: stateOf(lines
|
|
185
|
+
state: stateOf(lines),
|
|
139
186
|
questions: Record.map(rules, (rule, id) => {
|
|
140
187
|
const block = blocks?.[id];
|
|
141
188
|
return {
|
|
142
189
|
type: "choice" as const,
|
|
143
|
-
instructions:
|
|
190
|
+
instructions: {
|
|
191
|
+
question: `Which line of \`code\` most clearly ${offense(rule)}?`,
|
|
192
|
+
...ruleFields(rule),
|
|
193
|
+
},
|
|
144
194
|
criteria:
|
|
145
195
|
block === undefined
|
|
146
196
|
? lineCriteria(lines, 1, lines.length)
|
|
@@ -159,15 +209,74 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
159
209
|
}
|
|
160
210
|
return {
|
|
161
211
|
model,
|
|
162
|
-
state: stateOf(lines
|
|
163
|
-
questions: Record.map(rules, (rule
|
|
212
|
+
state: stateOf(lines),
|
|
213
|
+
questions: Record.map(rules, (rule) => ({
|
|
164
214
|
type: "choice" as const,
|
|
165
|
-
instructions:
|
|
215
|
+
instructions: {
|
|
216
|
+
question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
|
|
217
|
+
...ruleFields(rule),
|
|
218
|
+
},
|
|
166
219
|
criteria,
|
|
167
220
|
})),
|
|
168
221
|
};
|
|
169
222
|
};
|
|
170
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Whether Jev can take every request a file may need: its lines within two
|
|
226
|
+
* line choices, and its code beside the longest question any rule asks.
|
|
227
|
+
*/
|
|
228
|
+
export const fits = (lines: Lines, rules: Rules): boolean => {
|
|
229
|
+
if (lines.length > MAX_LINES) return false;
|
|
230
|
+
const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
|
|
231
|
+
const questions = [
|
|
232
|
+
...Object.values(judgeBody("", lines, rules).questions),
|
|
233
|
+
...Object.values(located.questions),
|
|
234
|
+
];
|
|
235
|
+
const longest = Math.max(0, ...questions.map(tokensOf));
|
|
236
|
+
return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export interface Body {
|
|
240
|
+
readonly model: string;
|
|
241
|
+
readonly state: unknown;
|
|
242
|
+
readonly questions: Readonly<Record<string, unknown>>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A body as requests Jev can take: its questions in order, with a new
|
|
247
|
+
* request whenever the next question would not fit beside the state and
|
|
248
|
+
* the questions before it.
|
|
249
|
+
*/
|
|
250
|
+
export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
251
|
+
const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
|
|
252
|
+
const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
|
|
253
|
+
for (const [id, question] of Object.entries(body.questions)) {
|
|
254
|
+
const tokens = tokensOf(question);
|
|
255
|
+
const last = requests.at(-1);
|
|
256
|
+
if (last !== undefined && last.tokens + tokens <= room) {
|
|
257
|
+
last.questions[id] = question;
|
|
258
|
+
last.tokens += tokens;
|
|
259
|
+
} else {
|
|
260
|
+
requests.push({ questions: { [id]: question }, tokens });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
267
|
+
export const judgeRequests = (lines: Lines, rules: Rules): number =>
|
|
268
|
+
requestsOf(judgeBody("", lines, rules)).length;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The requests locating `rules` in a file takes, split to fit as `locate`
|
|
272
|
+
* sends them: a block choice first for a long file, then a line choice. For a
|
|
273
|
+
* long file the line choice is counted before a block is chosen, so it can
|
|
274
|
+
* run high.
|
|
275
|
+
*/
|
|
276
|
+
export const locateRequests = (lines: Lines, rules: Rules): number =>
|
|
277
|
+
(needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
|
|
278
|
+
requestsOf(locateBody("", lines, rules)).length;
|
|
279
|
+
|
|
171
280
|
/** A choice keeps one criterion for "none", which leaves this many for partners. */
|
|
172
281
|
const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
|
|
173
282
|
|
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,94 @@ 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
|
-
model +
|
|
55
|
-
rule.description +
|
|
56
|
-
(rule.reference ?? "") +
|
|
57
|
-
(rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
|
|
58
|
-
);
|
|
61
|
+
sha256(`${model}\u0000${JSON.stringify(judgeQuestion(rule))}`);
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
102
|
+
(file: ScannedFile) =>
|
|
103
|
+
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
104
|
+
Effect.mapError(self, (problem) =>
|
|
105
|
+
problem instanceof JevUnavailable
|
|
106
|
+
? JevUnavailable.make({
|
|
107
|
+
message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
|
|
108
|
+
})
|
|
109
|
+
: problem,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
export const planAudit: Effect.Effect<
|
|
113
|
+
AuditPlan,
|
|
114
|
+
WalkUnavailable,
|
|
115
|
+
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
64
116
|
> = Effect.gen(function* () {
|
|
65
117
|
const config = yield* AdhereConfig;
|
|
66
|
-
const jev = yield* Jev;
|
|
67
118
|
const cache = yield* AuditCache;
|
|
68
119
|
const files = yield* (yield* SourceWalker).files;
|
|
69
120
|
|
|
70
121
|
const configuredRules = (file: string) =>
|
|
71
|
-
config.scopedRules === undefined
|
|
72
|
-
? config.rules
|
|
73
|
-
: applicableRules(file, config.scopedRules);
|
|
74
|
-
|
|
75
|
-
const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
|
|
122
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
76
123
|
|
|
77
|
-
const
|
|
78
|
-
|
|
124
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
125
|
+
const rules = configuredRules(file.path);
|
|
126
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
127
|
+
if (!fits(file.lines, rules)) {
|
|
128
|
+
return {
|
|
129
|
+
file,
|
|
130
|
+
skipped: true,
|
|
131
|
+
hash: "",
|
|
132
|
+
prepared: {},
|
|
133
|
+
kept: {},
|
|
134
|
+
pending: {},
|
|
135
|
+
} satisfies FilePlan;
|
|
136
|
+
}
|
|
79
137
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
80
|
-
Object.entries(
|
|
138
|
+
Object.entries(rules),
|
|
81
139
|
([id, rule]) =>
|
|
82
140
|
Effect.map(
|
|
83
141
|
fingerprintOf(config.model, rule),
|
|
84
142
|
(fingerprint) =>
|
|
85
|
-
[
|
|
86
|
-
id,
|
|
87
|
-
{ rule, fingerprint, threshold: rule.threshold ?? config.threshold },
|
|
88
|
-
] as const,
|
|
143
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
89
144
|
),
|
|
90
145
|
).pipe(Effect.map(Record.fromEntries));
|
|
91
146
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
@@ -96,64 +151,140 @@ export const runAudit: Effect.Effect<
|
|
|
96
151
|
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
97
152
|
);
|
|
98
153
|
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
154
|
+
return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
|
|
155
|
+
});
|
|
99
156
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
157
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
158
|
+
const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
159
|
+
concurrency: 8,
|
|
160
|
+
});
|
|
161
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
162
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
163
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
164
|
+
return {
|
|
165
|
+
files: planned,
|
|
166
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
167
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
168
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
169
|
+
skipped: planned.length - judged.length,
|
|
170
|
+
requests: total((plan) =>
|
|
171
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Judges what the plan left pending and locates the findings, a file at a
|
|
178
|
+
* time, telling `progress` as each file finishes.
|
|
179
|
+
*/
|
|
180
|
+
export const executeAudit = (
|
|
181
|
+
plan: AuditPlan,
|
|
182
|
+
progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
|
|
183
|
+
): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
|
|
184
|
+
Effect.gen(function* () {
|
|
185
|
+
const jev = yield* Jev;
|
|
186
|
+
const cache = yield* AuditCache;
|
|
187
|
+
|
|
188
|
+
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
189
|
+
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
190
|
+
file,
|
|
191
|
+
hash,
|
|
192
|
+
prepared,
|
|
193
|
+
kept,
|
|
194
|
+
pending,
|
|
195
|
+
}: FilePlan) {
|
|
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
|
+
});
|
|
106
222
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
return {
|
|
248
|
+
result: fileResult(cached ? "cached" : "judged", findings),
|
|
249
|
+
requests:
|
|
250
|
+
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
251
|
+
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
252
|
+
};
|
|
112
253
|
});
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
254
|
+
|
|
255
|
+
const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
|
|
256
|
+
const { result, requests } = plan.skipped
|
|
257
|
+
? { result: fileResult("skipped", []), requests: 0 }
|
|
258
|
+
: yield* judgeFile(plan).pipe(
|
|
259
|
+
// Jev counted more than `fits` estimated. Its count decides: skipped the same way.
|
|
260
|
+
Effect.catchTag("JevOverflow", () =>
|
|
261
|
+
Effect.succeed({
|
|
262
|
+
result: fileResult("skipped", []),
|
|
263
|
+
requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
yield* progress({ requests, findings: result.findings.length });
|
|
268
|
+
return result;
|
|
119
269
|
});
|
|
120
270
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
271
|
+
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
272
|
+
const count = (status: FileResult["status"]) =>
|
|
273
|
+
results.filter((result) => result.status === status).length;
|
|
274
|
+
return {
|
|
275
|
+
files: plan.files.length,
|
|
276
|
+
judged: count("judged"),
|
|
277
|
+
cached: count("cached"),
|
|
278
|
+
skipped: count("skipped"),
|
|
279
|
+
findings: results.flatMap((result) => result.findings),
|
|
280
|
+
};
|
|
144
281
|
});
|
|
145
282
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
judged: count("judged"),
|
|
153
|
-
cached: count("cached"),
|
|
154
|
-
skipped: count("skipped"),
|
|
155
|
-
findings: results.flatMap((result) => result.findings),
|
|
156
|
-
};
|
|
157
|
-
});
|
|
283
|
+
/** Plans the run and executes it, without asking and without a counter. */
|
|
284
|
+
export const runAudit: Effect.Effect<
|
|
285
|
+
AuditResult,
|
|
286
|
+
WalkUnavailable | JevUnavailable,
|
|
287
|
+
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
288
|
+
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
158
289
|
|
|
159
290
|
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")}`;
|