@drkmttr/adhere 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -32
- package/package.json +13 -12
- package/presets/effect/basics/external-calls-are-resilient.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +39 -15
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +1 -2
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +13 -17
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +92 -18
- package/src/services/Jev.ts +77 -7
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +210 -30
- package/src/workflows/format.ts +77 -21
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- package/presets/effect/testing/test-random.md +0 -22
|
@@ -1,21 +1,60 @@
|
|
|
1
1
|
import { CACHE_DIRECTORY } from "#config.ts";
|
|
2
|
-
import { Context, Crypto, Effect, Layer, Option, Path, Schema } from "effect";
|
|
3
|
-
import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";
|
|
2
|
+
import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Record, Schema } from "effect";
|
|
4
3
|
|
|
4
|
+
/** A rule's judgment as a run holds it: Jev's answer, with the fingerprint of the rule's text. */
|
|
5
5
|
export const Judgment = Schema.Struct({
|
|
6
6
|
fingerprint: Schema.String,
|
|
7
7
|
probability: Schema.Finite,
|
|
8
8
|
line: Schema.optionalKey(Schema.Finite),
|
|
9
|
-
snippet: Schema.optionalKey(Schema.String),
|
|
10
9
|
});
|
|
11
10
|
export interface Judgment extends Schema.Schema.Type<typeof Judgment> {}
|
|
12
11
|
|
|
12
|
+
/**
|
|
13
|
+
* An entry as adhere wrote it before the cache was keyed by content: one per
|
|
14
|
+
* file, under the hash of the file's absolute path. Read only to carry its
|
|
15
|
+
* judgments over. Entries written before the report read its excerpt from the
|
|
16
|
+
* file also hold the line's text, as `snippet`; decoding drops it.
|
|
17
|
+
*/
|
|
13
18
|
export const CacheEntry = Schema.Struct({
|
|
14
19
|
hash: Schema.String,
|
|
15
20
|
judgments: Schema.Record(Schema.String, Judgment),
|
|
16
21
|
});
|
|
17
22
|
export interface CacheEntry extends Schema.Schema.Type<typeof CacheEntry> {}
|
|
18
23
|
|
|
24
|
+
/** Jev's answer about some code for one rule text: the probability, and the line once located. */
|
|
25
|
+
export const Answer = Schema.Struct({
|
|
26
|
+
probability: Schema.Finite,
|
|
27
|
+
line: Schema.optionalKey(Schema.Finite),
|
|
28
|
+
});
|
|
29
|
+
export interface Answer extends Schema.Schema.Type<typeof Answer> {}
|
|
30
|
+
|
|
31
|
+
/** Answers about one file's content, each under the fingerprint of the rule text it answers. */
|
|
32
|
+
export type Answers = Readonly<Record<string, Answer>>;
|
|
33
|
+
|
|
34
|
+
const Entry = Schema.Struct({ answers: Schema.Record(Schema.String, Answer) });
|
|
35
|
+
const decodeEntry = Schema.decodeUnknownOption(Schema.fromJsonString(Entry));
|
|
36
|
+
const decodeOldEntry = Schema.decodeUnknownOption(Schema.fromJsonString(CacheEntry));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A rule's linter check so far: per file lint asked it on, Jev's probability
|
|
40
|
+
* that a regular linter could have decided the file against the rule.
|
|
41
|
+
*/
|
|
42
|
+
export const Tally = Schema.Struct({
|
|
43
|
+
files: Schema.Record(Schema.String, Schema.Finite),
|
|
44
|
+
});
|
|
45
|
+
export interface Tally extends Schema.Schema.Type<typeof Tally> {}
|
|
46
|
+
const decodeTally = Schema.decodeUnknownOption(Schema.fromJsonString(Tally));
|
|
47
|
+
|
|
48
|
+
/** What a run that read every file still needs from the cache. */
|
|
49
|
+
export interface Live {
|
|
50
|
+
/** The hash of each file's content. */
|
|
51
|
+
readonly hashes: ReadonlySet<string>;
|
|
52
|
+
/** The fingerprint of each rule text asked about a file. */
|
|
53
|
+
readonly fingerprints: ReadonlySet<string>;
|
|
54
|
+
/** The key of each rule's tally. */
|
|
55
|
+
readonly tallies: ReadonlySet<string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
19
58
|
const hexOf = (bytes: Uint8Array): string =>
|
|
20
59
|
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
21
60
|
|
|
@@ -25,38 +64,248 @@ export const sha256 = Effect.fn("AuditCache.sha256")(function* (text: string) {
|
|
|
25
64
|
return hexOf(digest);
|
|
26
65
|
});
|
|
27
66
|
|
|
67
|
+
/** Judgments by rule, as the answers the cache keeps: by the fingerprint of each rule's text. */
|
|
68
|
+
export const answersOf = (judgments: Readonly<Record<string, Judgment>>): Answers =>
|
|
69
|
+
Object.fromEntries(
|
|
70
|
+
Object.values(judgments).map(({ fingerprint, ...answer }) => [fingerprint, answer]),
|
|
71
|
+
);
|
|
72
|
+
|
|
28
73
|
export class AuditCache extends Context.Service<
|
|
29
74
|
AuditCache,
|
|
30
75
|
{
|
|
31
|
-
|
|
32
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Answers about content with this hash. `path` is where that content is
|
|
78
|
+
* now, to carry over an entry written before the cache was keyed by content.
|
|
79
|
+
*/
|
|
80
|
+
readonly get: (hash: string, path: string) => Effect.Effect<Answers>;
|
|
81
|
+
/** Keeps answers about content with this hash, beside the ones already kept. */
|
|
82
|
+
readonly put: (hash: string, answers: Answers) => Effect.Effect<void>;
|
|
83
|
+
/** A rule's tally, under a key that changes with the rule's text. */
|
|
84
|
+
readonly tally: (key: string) => Effect.Effect<Tally | undefined>;
|
|
85
|
+
readonly putTally: (key: string, tally: Tally) => Effect.Effect<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Deletes what `live` does not need, and folds the rest into one file per
|
|
88
|
+
* content and per tally. Only after a run that read every file: it cannot
|
|
89
|
+
* tell what the files it did not read need. Succeeds with the files deleted.
|
|
90
|
+
*/
|
|
91
|
+
readonly prune: (live: Live) => Effect.Effect<number>;
|
|
33
92
|
}
|
|
34
93
|
>()("@drkmttr/adhere/services/AuditCache") {}
|
|
35
94
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
95
|
+
/** Keys in order, so the same content always writes the same text. */
|
|
96
|
+
const sorted = <A>(record: Readonly<Record<string, A>>): Record<string, A> =>
|
|
97
|
+
Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
98
|
+
|
|
99
|
+
const textOf = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
|
|
100
|
+
|
|
101
|
+
/** One answer per rule text: one that has its line over one that has not, otherwise the first. */
|
|
102
|
+
const unionOf = (entries: ReadonlyArray<Answers>): Answers => {
|
|
103
|
+
const union: Record<string, Answer> = {};
|
|
104
|
+
for (const answers of entries) {
|
|
105
|
+
for (const [fingerprint, answer] of Object.entries(answers)) {
|
|
106
|
+
const held = union[fingerprint];
|
|
107
|
+
if (held === undefined || (held.line === undefined && answer.line !== undefined)) {
|
|
108
|
+
union[fingerprint] = answer;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return union;
|
|
113
|
+
};
|
|
42
114
|
|
|
115
|
+
/** One answer per file, the first. */
|
|
116
|
+
const tallyUnionOf = (tallies: ReadonlyArray<Tally>): Tally => {
|
|
117
|
+
const files: Record<string, number> = {};
|
|
118
|
+
for (const tally of tallies) {
|
|
119
|
+
for (const [file, probability] of Object.entries(tally.files)) files[file] ??= probability;
|
|
120
|
+
}
|
|
121
|
+
return { files };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** A group's file: the group, then the hash of the file's text. */
|
|
125
|
+
const NAME = /^([^.]+)\.[0-9a-f]{16}\.json$/;
|
|
126
|
+
/** A file from the layout before content keys: the hash of a path or of a tally's key. */
|
|
127
|
+
const OLD_NAME = /^[0-9a-f]{64}$/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `.adhere/cache/`, which is meant to be committed. `files/` holds answers by
|
|
131
|
+
* the content they are about, and `tallies/` the linter check's tallies, with
|
|
132
|
+
* paths from the working directory. A file is written once and never
|
|
133
|
+
* rewritten: it is named for its group, a content hash or a tally key, and for
|
|
134
|
+
* the hash of its own text. Two branches that add to a group add different
|
|
135
|
+
* files, or the same one, so git merges them without a conflict. Reading a
|
|
136
|
+
* group takes the union of its files, and pruning folds them back into one.
|
|
137
|
+
*
|
|
138
|
+
* A cache must never fail a run: whatever cannot be read counts as missing,
|
|
139
|
+
* and whatever cannot be written is left unwritten.
|
|
140
|
+
*/
|
|
43
141
|
export const AuditCacheLive = Layer.effect(AuditCache)(
|
|
44
142
|
Effect.gen(function* () {
|
|
143
|
+
const fs = yield* FileSystem.FileSystem;
|
|
45
144
|
const path = yield* Path.Path;
|
|
46
|
-
const store = yield* openStore(path.join(path.resolve(), CACHE_DIRECTORY));
|
|
47
|
-
const entries = KeyValueStore.toSchemaStore(store, CacheEntry);
|
|
48
145
|
const crypto = yield* Crypto.Crypto;
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
146
|
+
const root = path.resolve();
|
|
147
|
+
const directory = path.join(root, CACHE_DIRECTORY);
|
|
148
|
+
const hash = (text: string) => sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
|
|
149
|
+
|
|
150
|
+
/** Each group's file names in one folder, listed once and kept current as files come and go. */
|
|
151
|
+
const indexOf = (folder: string) =>
|
|
152
|
+
Effect.cached(
|
|
153
|
+
fs.readDirectory(path.join(directory, folder)).pipe(
|
|
154
|
+
Effect.orElseSucceed((): ReadonlyArray<string> => []),
|
|
155
|
+
Effect.map((names) => {
|
|
156
|
+
const groups = new Map<string, Set<string>>();
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
const group = NAME.exec(name)?.[1];
|
|
159
|
+
if (group !== undefined)
|
|
160
|
+
groups.set(group, (groups.get(group) ?? new Set()).add(name));
|
|
161
|
+
}
|
|
162
|
+
return groups;
|
|
163
|
+
}),
|
|
57
164
|
),
|
|
58
|
-
|
|
59
|
-
|
|
165
|
+
);
|
|
166
|
+
const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
|
|
167
|
+
type Folder = keyof typeof indexes;
|
|
168
|
+
|
|
169
|
+
const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
|
|
170
|
+
fs.readFileString(file).pipe(
|
|
171
|
+
Effect.map((text) => Option.getOrUndefined(decode(text))),
|
|
172
|
+
Effect.orElseSucceed(() => undefined),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** A group's files that decode, in name order. */
|
|
176
|
+
const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
|
|
177
|
+
folder: Folder,
|
|
178
|
+
group: string,
|
|
179
|
+
decode: (text: string) => Option.Option<A>,
|
|
180
|
+
) {
|
|
181
|
+
const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
|
|
182
|
+
const decoded = yield* Effect.forEach(names, (name) =>
|
|
183
|
+
read(decode, path.join(directory, folder, name)),
|
|
184
|
+
);
|
|
185
|
+
return decoded.filter((value): value is A => value !== undefined);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const nameOf = (group: string, text: string) =>
|
|
189
|
+
Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
|
|
190
|
+
|
|
191
|
+
/** Adds `value` to a group as a new file, unless the group has that file already. */
|
|
192
|
+
const add = Effect.fn("AuditCache.add")(function* (
|
|
193
|
+
folder: Folder,
|
|
194
|
+
group: string,
|
|
195
|
+
value: unknown,
|
|
196
|
+
) {
|
|
197
|
+
const text = textOf(value);
|
|
198
|
+
const name = yield* nameOf(group, text);
|
|
199
|
+
const index = yield* indexes[folder];
|
|
200
|
+
const names = index.get(group) ?? new Set<string>();
|
|
201
|
+
if (names.has(name)) return;
|
|
202
|
+
const written = yield* fs
|
|
203
|
+
.makeDirectory(path.join(directory, folder), { recursive: true })
|
|
204
|
+
.pipe(
|
|
205
|
+
Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
|
|
206
|
+
Effect.as(true),
|
|
207
|
+
Effect.orElseSucceed(() => false),
|
|
208
|
+
);
|
|
209
|
+
if (written) index.set(group, names.add(name));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const remove = (file: string) =>
|
|
213
|
+
fs.remove(file).pipe(
|
|
214
|
+
Effect.as(1),
|
|
215
|
+
Effect.orElseSucceed(() => 0),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
/** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
|
|
219
|
+
const fold = Effect.fn("AuditCache.fold")(function* (
|
|
220
|
+
folder: Folder,
|
|
221
|
+
group: string,
|
|
222
|
+
value: unknown,
|
|
223
|
+
) {
|
|
224
|
+
const index = yield* indexes[folder];
|
|
225
|
+
const names = [...(index.get(group) ?? [])];
|
|
226
|
+
const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
|
|
227
|
+
if (value !== undefined) yield* add(folder, group, value);
|
|
228
|
+
let deleted = 0;
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
|
|
231
|
+
}
|
|
232
|
+
if (keep === undefined) index.delete(group);
|
|
233
|
+
else index.set(group, new Set([keep]));
|
|
234
|
+
return deleted;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const relativeOf = (tally: Tally): Tally => ({
|
|
238
|
+
files: sorted(
|
|
239
|
+
Record.mapKeys(tally.files, (file) =>
|
|
240
|
+
path.isAbsolute(file) ? path.relative(root, file) : file,
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
|
|
246
|
+
const entries = yield* readGroup("files", contentHash, decodeEntry);
|
|
247
|
+
if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
|
|
248
|
+
// Before content keys, a file's entry sat under the hash of its absolute path.
|
|
249
|
+
const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
|
|
250
|
+
if (old === undefined || old.hash !== contentHash) return {};
|
|
251
|
+
const answers = answersOf(old.judgments);
|
|
252
|
+
yield* add("files", contentHash, { answers: sorted(answers) });
|
|
253
|
+
return answers;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
|
|
257
|
+
const tallies = yield* readGroup("tallies", key, decodeTally);
|
|
258
|
+
if (tallies.length > 0) {
|
|
259
|
+
const { files } = tallyUnionOf(tallies);
|
|
260
|
+
return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
|
|
261
|
+
}
|
|
262
|
+
// Before, a tally sat under the hash of its key, with absolute paths.
|
|
263
|
+
const old = yield* read(
|
|
264
|
+
decodeTally,
|
|
265
|
+
path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
|
|
266
|
+
);
|
|
267
|
+
if (old !== undefined) yield* add("tallies", key, relativeOf(old));
|
|
268
|
+
return old;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
|
|
272
|
+
// Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
|
|
273
|
+
yield* Effect.forEach(live.tallies, tally, { discard: true });
|
|
274
|
+
let deleted = 0;
|
|
275
|
+
// Folding replaces or removes the group being visited, which a Map's iteration allows.
|
|
276
|
+
for (const group of (yield* indexes.files).keys()) {
|
|
277
|
+
const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
|
|
278
|
+
const answers = Record.filter(
|
|
279
|
+
unionOf(entries.map((entry) => entry.answers)),
|
|
280
|
+
(_, fingerprint) => live.fingerprints.has(fingerprint),
|
|
281
|
+
);
|
|
282
|
+
deleted += yield* fold(
|
|
283
|
+
"files",
|
|
284
|
+
group,
|
|
285
|
+
Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
for (const key of (yield* indexes.tallies).keys()) {
|
|
289
|
+
const tallies = live.tallies.has(key) ? yield* readGroup("tallies", key, decodeTally) : [];
|
|
290
|
+
deleted += yield* fold(
|
|
291
|
+
"tallies",
|
|
292
|
+
key,
|
|
293
|
+
tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
|
|
297
|
+
for (const name of old) {
|
|
298
|
+
if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
|
|
299
|
+
}
|
|
300
|
+
return deleted;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return AuditCache.of({
|
|
304
|
+
get,
|
|
305
|
+
put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
|
|
306
|
+
tally,
|
|
307
|
+
putTally: (key, value) => add("tallies", key, relativeOf(value)),
|
|
308
|
+
prune,
|
|
60
309
|
});
|
|
61
310
|
}),
|
|
62
311
|
);
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RuleId } from "#config.ts";
|
|
1
2
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
2
3
|
import { Credentials } from "#services/Credentials.ts";
|
|
3
4
|
import {
|
|
@@ -7,10 +8,13 @@ import {
|
|
|
7
8
|
conflictBody,
|
|
8
9
|
contradictBody,
|
|
9
10
|
Jev,
|
|
11
|
+
JevBlocked,
|
|
10
12
|
JevOverflow,
|
|
11
13
|
JevUnavailable,
|
|
14
|
+
type Judged,
|
|
12
15
|
judgeBody,
|
|
13
16
|
type Lines,
|
|
17
|
+
linterKey,
|
|
14
18
|
locateBody,
|
|
15
19
|
namedPairs,
|
|
16
20
|
needsBlocks,
|
|
@@ -18,6 +22,7 @@ import {
|
|
|
18
22
|
pairProbability,
|
|
19
23
|
requestsOf,
|
|
20
24
|
type Rules,
|
|
25
|
+
tokensOf,
|
|
21
26
|
} from "#services/Jev.ts";
|
|
22
27
|
import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
28
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
@@ -35,6 +40,14 @@ const ChoiceAnswers = Schema.Struct({
|
|
|
35
40
|
|
|
36
41
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
37
42
|
|
|
43
|
+
/** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
|
|
44
|
+
const attemptFailure = (
|
|
45
|
+
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
46
|
+
): string =>
|
|
47
|
+
HttpClientError.isHttpClientError(problem) && problem.reason._tag === "StatusCodeError"
|
|
48
|
+
? `HTTP ${problem.reason.response.status}, Ray ID ${problem.reason.response.headers["cf-ray"] ?? "none"}`
|
|
49
|
+
: problem.message;
|
|
50
|
+
|
|
38
51
|
/** Jev's error body, on one line and cut short, or nothing when it sent none. */
|
|
39
52
|
const detailOf = (body: string): string => {
|
|
40
53
|
const text = body.trim().replace(/\s+/g, " ");
|
|
@@ -48,6 +61,11 @@ const Overflowed = Schema.fromJsonString(
|
|
|
48
61
|
const isOverflow = (body: string): boolean =>
|
|
49
62
|
Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
|
|
50
63
|
|
|
64
|
+
/** A firewall's block page: HTML where Jev answers JSON, even for its own errors. */
|
|
65
|
+
const isBlockPage = (response: HttpClientResponse.HttpClientResponse, body: string): boolean =>
|
|
66
|
+
(response.headers["content-type"] ?? "").includes("text/html") ||
|
|
67
|
+
body.trimStart().startsWith("<");
|
|
68
|
+
|
|
51
69
|
/**
|
|
52
70
|
* A request that failed. A 400 saying the request is over Jev's context is a
|
|
53
71
|
* `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
|
|
@@ -62,20 +80,41 @@ const failed = (
|
|
|
62
80
|
}
|
|
63
81
|
const { response } = problem.reason;
|
|
64
82
|
return Effect.flatMap(
|
|
65
|
-
Effect.orElseSucceed(response.text, () => "")
|
|
66
|
-
|
|
83
|
+
Effect.orElseSucceed(response.text, () => "").pipe(
|
|
84
|
+
Effect.tap((body) =>
|
|
85
|
+
Effect.logDebug(
|
|
86
|
+
`Jev answered HTTP ${response.status}, ${response.headers["content-type"] ?? "no content type"}, ${body.length} characters, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
Effect.tap((body) => Effect.logTrace(`Jev's answer: ${body.slice(0, 4000)}`)),
|
|
90
|
+
),
|
|
91
|
+
(body): Effect.Effect<never, JevOverflow | JevBlocked | JevUnavailable> =>
|
|
67
92
|
response.status === 400 && isOverflow(body)
|
|
68
93
|
? Effect.fail(JevOverflow.make())
|
|
69
|
-
:
|
|
94
|
+
: response.status === 403 && isBlockPage(response, body)
|
|
95
|
+
? Effect.fail(JevBlocked.make({ ray: response.headers["cf-ray"] ?? "none given" }))
|
|
96
|
+
: Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
|
|
70
97
|
);
|
|
71
98
|
};
|
|
72
99
|
|
|
73
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Comparing rules has no file to skip, so a request over Jev's context, or one
|
|
102
|
+
* the firewall blocks, refuses instead.
|
|
103
|
+
*/
|
|
74
104
|
const refuseOverflow = <A>(
|
|
75
|
-
self: Effect.Effect<A, JevOverflow | JevUnavailable>,
|
|
105
|
+
self: Effect.Effect<A, JevOverflow | JevBlocked | JevUnavailable>,
|
|
76
106
|
): Effect.Effect<A, JevUnavailable> =>
|
|
77
|
-
|
|
78
|
-
Effect.
|
|
107
|
+
self.pipe(
|
|
108
|
+
Effect.catchTag("JevOverflow", () =>
|
|
109
|
+
Effect.fail(refused("Comparing the rules took a request over Jev's context")),
|
|
110
|
+
),
|
|
111
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
112
|
+
Effect.fail(
|
|
113
|
+
refused(
|
|
114
|
+
`The firewall in front of Jev's API blocked comparing the rules (Cloudflare Ray ID ${ray})`,
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
79
118
|
);
|
|
80
119
|
|
|
81
120
|
export const JevLive = Layer.effect(Jev)(
|
|
@@ -104,13 +143,18 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
104
143
|
);
|
|
105
144
|
const client = paced.pipe(
|
|
106
145
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
146
|
+
// Every failed attempt, including those retried, so a log shows what retrying hid.
|
|
147
|
+
HttpClient.transformResponse(
|
|
148
|
+
Effect.tapError((problem) => Effect.logDebug(`attempt failed: ${attemptFailure(problem)}`)),
|
|
149
|
+
),
|
|
107
150
|
HttpClient.retryTransient({
|
|
108
151
|
schedule: Schedule.exponential("500 millis"),
|
|
109
152
|
times: 3,
|
|
110
153
|
}),
|
|
111
154
|
);
|
|
112
155
|
|
|
113
|
-
|
|
156
|
+
/** One request. `kind` names the question for the log: judge, locate, blocks, and so on. */
|
|
157
|
+
const ask = <A>(kind: string, body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
|
|
114
158
|
Effect.gen(function* () {
|
|
115
159
|
// Read here, not in the layer: a run with nothing pending needs no key.
|
|
116
160
|
const apiKey = yield* credentials.apiKey.pipe(
|
|
@@ -120,9 +164,16 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
120
164
|
HttpClientRequest.post(SYSTEM_ONE),
|
|
121
165
|
body,
|
|
122
166
|
).pipe(Effect.orDie);
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
const questions = Object.keys(body.questions).length;
|
|
168
|
+
yield* Effect.logDebug(
|
|
169
|
+
`${kind}: sending ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
|
|
170
|
+
);
|
|
171
|
+
const [elapsed, response] = yield* Effect.timed(
|
|
172
|
+
client.execute(HttpClientRequest.bearerToken(request, apiKey)).pipe(Effect.catch(failed)),
|
|
173
|
+
);
|
|
174
|
+
yield* Effect.logDebug(
|
|
175
|
+
`${kind}: HTTP ${response.status} in ${Math.round(Duration.toMillis(elapsed))} ms, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
|
|
176
|
+
);
|
|
126
177
|
return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
|
|
127
178
|
Effect.mapError((problem) =>
|
|
128
179
|
refused(`System One response did not decode: ${problem.message}`),
|
|
@@ -132,6 +183,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
132
183
|
|
|
133
184
|
/** Every answer to a body, over as many requests as Jev's context needs. */
|
|
134
185
|
const answersTo = <A>(
|
|
186
|
+
kind: string,
|
|
135
187
|
body: Body,
|
|
136
188
|
Answers: Schema.Codec<
|
|
137
189
|
{ readonly answers: Readonly<Record<string, A>> },
|
|
@@ -141,7 +193,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
141
193
|
>,
|
|
142
194
|
) =>
|
|
143
195
|
Effect.map(
|
|
144
|
-
Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
|
|
196
|
+
Effect.forEach(requestsOf(body), (request) => ask(kind, request, Answers)),
|
|
145
197
|
(responses) =>
|
|
146
198
|
responses.reduce<Record<string, A>>(
|
|
147
199
|
(answers, response) => ({ ...answers, ...response.answers }),
|
|
@@ -149,9 +201,28 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
149
201
|
),
|
|
150
202
|
);
|
|
151
203
|
|
|
152
|
-
const judge = Effect.fn("Jev.judge")(function* (
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
const judge = Effect.fn("Jev.judge")(function* (
|
|
205
|
+
lines: Lines,
|
|
206
|
+
rules: Rules,
|
|
207
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
208
|
+
) {
|
|
209
|
+
const answers = yield* answersTo(
|
|
210
|
+
"judge",
|
|
211
|
+
judgeBody(config.model, lines, rules, sampled),
|
|
212
|
+
NoulAnswers,
|
|
213
|
+
);
|
|
214
|
+
/** Each rule's answer, read from the question it rode under. */
|
|
215
|
+
const answered = (ids: ReadonlyArray<RuleId>, keyOf: (id: RuleId) => string) =>
|
|
216
|
+
Record.fromEntries(
|
|
217
|
+
ids.flatMap((id) => {
|
|
218
|
+
const answer = answers[keyOf(id)];
|
|
219
|
+
return answer === undefined ? [] : [[id, answer.noul] as const];
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
return {
|
|
223
|
+
probabilities: answered(Object.keys(rules), (id) => id),
|
|
224
|
+
linter: answered(sampled, linterKey),
|
|
225
|
+
} satisfies Judged;
|
|
155
226
|
});
|
|
156
227
|
|
|
157
228
|
const chosen = (answers: Readonly<Record<string, { choice: string }>>) =>
|
|
@@ -159,10 +230,10 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
159
230
|
|
|
160
231
|
const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
|
|
161
232
|
const blocks = needsBlocks(lines)
|
|
162
|
-
? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
233
|
+
? chosen(yield* answersTo("blocks", blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
163
234
|
: undefined;
|
|
164
235
|
return chosen(
|
|
165
|
-
yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
236
|
+
yield* answersTo("locate", locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
166
237
|
);
|
|
167
238
|
});
|
|
168
239
|
|
|
@@ -171,6 +242,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
171
242
|
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
172
243
|
) {
|
|
173
244
|
const answers = yield* answersTo(
|
|
245
|
+
"conflicts",
|
|
174
246
|
conflictBody(config.model, rules, partners),
|
|
175
247
|
ChoiceAnswers,
|
|
176
248
|
).pipe(refuseOverflow);
|
|
@@ -185,7 +257,9 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
185
257
|
pairs,
|
|
186
258
|
(pair) =>
|
|
187
259
|
Effect.map(
|
|
188
|
-
ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(
|
|
260
|
+
ask("contradicts", contradictBody(config.model, rules, pair), NoulAnswers).pipe(
|
|
261
|
+
refuseOverflow,
|
|
262
|
+
),
|
|
189
263
|
({ answers }) => pairProbability(answers, pair),
|
|
190
264
|
),
|
|
191
265
|
{ concurrency: 8 },
|
package/src/services/Jev.ts
CHANGED
|
@@ -14,17 +14,41 @@ export class JevUnavailable extends Schema.TaggedError<JevUnavailable>()("JevUna
|
|
|
14
14
|
*/
|
|
15
15
|
export class JevOverflow extends Schema.TaggedError<JevOverflow>()("JevOverflow", {}) {}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A request the firewall in front of Jev's API refused: a 403 with an HTML
|
|
19
|
+
* block page where Jev answers JSON. It reads some code as an attack, so the
|
|
20
|
+
* same request is refused every time while the rest of a run goes through.
|
|
21
|
+
* `ray` is Cloudflare's Ray ID, which TypeSafe AI can look the block up by.
|
|
22
|
+
*/
|
|
23
|
+
export class JevBlocked extends Schema.TaggedError<JevBlocked>()("JevBlocked", {
|
|
24
|
+
ray: Schema.String,
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
27
|
+
/** What judging a file answers: per rule, whether it breaks it, and the linter check's answers. */
|
|
28
|
+
export interface Judged {
|
|
29
|
+
/** Per rule, Jev's probability that the file breaks it. */
|
|
30
|
+
readonly probabilities: Readonly<Record<RuleId, number>>;
|
|
31
|
+
/** Per sampled rule, Jev's probability that a regular linter could have decided the file against it. */
|
|
32
|
+
readonly linter: Readonly<Record<RuleId, number>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export class Jev extends Context.Service<
|
|
18
36
|
Jev,
|
|
19
37
|
{
|
|
38
|
+
/**
|
|
39
|
+
* Each rule's probability that the file breaks it. A rule in `sampled`
|
|
40
|
+
* also carries the linter check's question, whose answer comes back
|
|
41
|
+
* under `linter`.
|
|
42
|
+
*/
|
|
20
43
|
readonly judge: (
|
|
21
44
|
lines: Lines,
|
|
22
45
|
rules: Rules,
|
|
23
|
-
|
|
46
|
+
sampled?: ReadonlyArray<RuleId>,
|
|
47
|
+
) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
|
|
24
48
|
readonly locate: (
|
|
25
49
|
lines: Lines,
|
|
26
50
|
rules: Rules,
|
|
27
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
51
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow | JevBlocked>;
|
|
28
52
|
/**
|
|
29
53
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
30
54
|
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
@@ -79,7 +103,7 @@ const encoder = new TextEncoder();
|
|
|
79
103
|
export const tokensOf = (value: unknown): number =>
|
|
80
104
|
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
81
105
|
|
|
82
|
-
|
|
106
|
+
const snippetOf = (line: string): string => line.trim().slice(0, 120);
|
|
83
107
|
|
|
84
108
|
const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
85
109
|
|
|
@@ -183,10 +207,53 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
183
207
|
return criteria;
|
|
184
208
|
};
|
|
185
209
|
|
|
186
|
-
|
|
210
|
+
/**
|
|
211
|
+
* The linter check's question: whether a regular linter could have decided
|
|
212
|
+
* `code` against the rule. It carries the judge question's fields, so Jev
|
|
213
|
+
* sees the rule as it does when judging, with the file in view.
|
|
214
|
+
*/
|
|
215
|
+
export const linterQuestion = (rule: Rule) => {
|
|
216
|
+
const { instructions } = judgeQuestion(rule);
|
|
217
|
+
return {
|
|
218
|
+
type: "noul" as const,
|
|
219
|
+
instructions: {
|
|
220
|
+
...instructions,
|
|
221
|
+
question:
|
|
222
|
+
"Should `rule` have been checked by a regular linter? Consider `code`: could a linter or type checker have decided exactly whether it follows `rule`, without judgment?",
|
|
223
|
+
},
|
|
224
|
+
criteria: {
|
|
225
|
+
true: "A regular linter or type checker could decide exactly whether `code` follows `rule`",
|
|
226
|
+
false:
|
|
227
|
+
"Deciding whether `code` follows `rule` takes judgment about what the code means or is for",
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** The key a rule's linter question rides under, beside its judge question. */
|
|
233
|
+
export const linterKey = (id: RuleId): string => `linter:${id}`;
|
|
234
|
+
|
|
235
|
+
/** The tokens a file's code leaves in Jev's context for any one question beside it, as `fits` counts them. */
|
|
236
|
+
export const questionRoom = (lines: Lines): number =>
|
|
237
|
+
QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines));
|
|
238
|
+
|
|
239
|
+
/** One question per rule, and the linter question beside each rule in `sampled`. */
|
|
240
|
+
export const judgeBody = (
|
|
241
|
+
model: string,
|
|
242
|
+
lines: Lines,
|
|
243
|
+
rules: Rules,
|
|
244
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
245
|
+
) => ({
|
|
187
246
|
model,
|
|
188
247
|
state: stateOf(lines),
|
|
189
|
-
questions:
|
|
248
|
+
questions: {
|
|
249
|
+
...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
|
|
250
|
+
...Object.fromEntries(
|
|
251
|
+
sampled.flatMap((id) => {
|
|
252
|
+
const rule = rules[id];
|
|
253
|
+
return rule === undefined ? [] : [[linterKey(id), linterQuestion(rule)] as const];
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
},
|
|
190
257
|
});
|
|
191
258
|
|
|
192
259
|
export const locateBody = (
|
|
@@ -278,8 +345,11 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
278
345
|
};
|
|
279
346
|
|
|
280
347
|
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
281
|
-
export const judgeRequests = (
|
|
282
|
-
|
|
348
|
+
export const judgeRequests = (
|
|
349
|
+
lines: Lines,
|
|
350
|
+
rules: Rules,
|
|
351
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
352
|
+
): number => requestsOf(judgeBody("", lines, rules, sampled)).length;
|
|
283
353
|
|
|
284
354
|
/**
|
|
285
355
|
* The requests locating `rules` in a file takes, split to fit as `locate`
|