@drkmttr/adhere 0.9.3 → 0.9.5
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 +29 -8
- package/bin/adhere.js +9 -0
- package/package.json +6 -6
- package/src/cli.ts +63 -10
- package/src/contradictions.ts +4 -2
- package/src/mechanical.ts +2 -2
- package/src/presets.ts +3 -0
- package/src/pricing.ts +14 -0
- package/src/rules.ts +36 -5
- package/src/services/AdhereConfig.ts +5 -2
- package/src/services/AuditCache.ts +10 -13
- package/src/services/Jev.ts +17 -0
- package/src/services/SourceWalker.ts +80 -28
- package/src/services/Status.ts +31 -0
- package/src/walk.ts +85 -0
- package/src/wording.ts +2 -2
- package/src/workflows/audit.ts +66 -23
- package/src/workflows/format.ts +43 -3
package/README.md
CHANGED
|
@@ -66,7 +66,10 @@ Found 1 error.
|
|
|
66
66
|
42 files, 3 judged, 39 cached.
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
The header is the rule id, Jev's probability, and the rule's description.
|
|
69
|
+
The header is the rule id, Jev's probability, and the rule's description. A
|
|
70
|
+
preset's rule has the preset's name first, as in
|
|
71
|
+
`effect/basics/external-calls-are-resilient`, so a report that mixes presets
|
|
72
|
+
with a repo's own rules says where each came from.
|
|
70
73
|
Under it is the line Jev points at, underlined, with the code around it: the
|
|
71
74
|
largest statement around the line that is 30 lines or fewer, usually the whole
|
|
72
75
|
function, and up to 3 lines of whole statements on either side. The
|
|
@@ -101,15 +104,27 @@ prints a completion script.
|
|
|
101
104
|
|
|
102
105
|
### Before and during a run
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
Until it knows what judging takes, a status line on stderr says what `lint` is
|
|
108
|
+
doing: looking for `.adhere/` rules, listing and reading the files, then
|
|
109
|
+
planning, each with a count, such as `Planning: 5,120 of 10,333 files`. The
|
|
110
|
+
line is drawn only on a terminal, and not at `--log-level debug` or below,
|
|
111
|
+
whose log lines would land in it. Then `lint` says on stderr what it found and
|
|
112
|
+
what judging takes:
|
|
106
113
|
|
|
107
114
|
```text
|
|
108
115
|
200 files and 14 rules: 2800 checks, 1400 cached.
|
|
109
116
|
Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
|
|
110
|
-
|
|
117
|
+
Those carry about 1.9 million input tokens: about $0.08 at $0.042 per million, and more for locating findings.
|
|
118
|
+
? Send 200 requests to Jev, about $0.08? › (Y/n)
|
|
111
119
|
```
|
|
112
120
|
|
|
121
|
+
The cost is adhere's estimate of the input tokens times the model's price.
|
|
122
|
+
Jev charges only for input tokens: $0.042 a million for `jev-latest`, per
|
|
123
|
+
[TypeSafe AI's models page](https://docs.typesafe.ai/models) in September 2026.
|
|
124
|
+
The estimate runs about a tenth high, and locating findings, which depends on
|
|
125
|
+
what judging finds, comes on top. For a model adhere has no price for, the plan
|
|
126
|
+
gives the tokens alone.
|
|
127
|
+
|
|
113
128
|
It asks only with a terminal on stdin and stdout, and never when the cache
|
|
114
129
|
answers every check; `--yes` sends without asking. While it runs, a counter on
|
|
115
130
|
stderr shows the files done, the requests sent, and the findings so far, and
|
|
@@ -370,8 +385,11 @@ each rule worded otherwise.
|
|
|
370
385
|
### Presets
|
|
371
386
|
|
|
372
387
|
A preset has the shape of a config without `presets`: `rules`, and optionally
|
|
373
|
-
`model` and `threshold`. Name
|
|
374
|
-
`--preset`,
|
|
388
|
+
`model` and `threshold`. Name presets in the config's `presets`, or on the
|
|
389
|
+
command line with `--preset`, repeated or separated by commas, as in
|
|
390
|
+
`--preset effect,alchemy`, in which case the config file is optional. Presets
|
|
391
|
+
named in both places apply together. Reports name a preset's rule with the
|
|
392
|
+
preset first, as in `alchemy/secrets/…`. There are two:
|
|
375
393
|
|
|
376
394
|
- `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
|
|
377
395
|
from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
|
|
@@ -489,8 +507,11 @@ A cache file is written once and never changed: it is also named for the hash
|
|
|
489
507
|
of its own text. Two branches that judge the same code add files rather than
|
|
490
508
|
edit them, so git merges the cache without a conflict. After a run that reads
|
|
491
509
|
every file, adhere prunes the cache: it deletes answers about content no file
|
|
492
|
-
has and
|
|
493
|
-
|
|
510
|
+
has, and folds what is left into one file per content. Answers to rules the
|
|
511
|
+
run left out stay, so a run with other presets or rules, or with one topic,
|
|
512
|
+
loses nothing another run still asks; answers to a rule's old texts stay too,
|
|
513
|
+
until the content they are about is gone. A run narrowed by `--filter` does not
|
|
514
|
+
prune. To keep the
|
|
494
515
|
cache out of diffs, mark it as generated in `.gitattributes`:
|
|
495
516
|
|
|
496
517
|
```text
|
package/bin/adhere.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
|
+
import { constants } from "node:os";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
|
|
13
14
|
const platform = `${process.platform}-${process.arch}`;
|
|
@@ -26,6 +27,14 @@ const resolveExecutable = () => {
|
|
|
26
27
|
const run = (command, commandArgs) => {
|
|
27
28
|
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
|
|
28
29
|
if (result.error) throw result.error;
|
|
30
|
+
if (result.signal) {
|
|
31
|
+
// A killed process says nothing itself, so say what killed it.
|
|
32
|
+
const memory =
|
|
33
|
+
result.signal === "SIGKILL" ? ", usually the system ending it for running out of memory" : "";
|
|
34
|
+
console.error(`adhere: the executable was killed by ${result.signal}${memory}.`);
|
|
35
|
+
process.exitCode = 128 + (constants.signals[result.signal] ?? 0);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
29
38
|
process.exitCode = result.status ?? 1;
|
|
30
39
|
};
|
|
31
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.9.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.9.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.9.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.9.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.9.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.9.5",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.9.5",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.9.5",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.9.5",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.9.5"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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 { Status } from "#services/Status.ts";
|
|
6
7
|
import {
|
|
7
8
|
type AuditPlan,
|
|
8
9
|
executeAudit,
|
|
@@ -61,10 +62,31 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
|
|
|
61
62
|
readonly [Runtime.errorReported] = false;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/** The names each `--preset` gives, split at commas: `--preset effect,alchemy` gives two. */
|
|
66
|
+
const presetsIn = (values: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
67
|
+
values
|
|
68
|
+
.flatMap((value) => value.split(","))
|
|
69
|
+
.map((name) => name.trim())
|
|
70
|
+
.filter((name) => name.length > 0);
|
|
71
|
+
|
|
72
|
+
const isPresetName = (name: string): name is PresetName =>
|
|
73
|
+
(presetNames as ReadonlyArray<string>).includes(name);
|
|
74
|
+
|
|
75
|
+
const preset = Flag.string("preset").pipe(
|
|
76
|
+
Flag.atLeast(0),
|
|
77
|
+
Flag.filterMap(
|
|
78
|
+
(values): Option.Option<ReadonlyArray<PresetName>> => {
|
|
79
|
+
const names = presetsIn(values);
|
|
80
|
+
return names.every(isPresetName) ? Option.some([...new Set(names)]) : Option.none();
|
|
81
|
+
},
|
|
82
|
+
// The CLI puts this after "Expected:".
|
|
83
|
+
(values) => {
|
|
84
|
+
const unknown = presetsIn(values).filter((name) => !isPresetName(name));
|
|
85
|
+
return `one of ${presetNames.join(", ")}, or several separated by commas (not ${unknown.join(", ")})`;
|
|
86
|
+
},
|
|
87
|
+
),
|
|
66
88
|
Flag.withDescription(
|
|
67
|
-
|
|
89
|
+
`Add built-in rule sets, or their topics, such as effect/basics. Repeat the flag or separate names with commas, as in --preset effect,alchemy. With a preset, .adhere/config.ts is optional. (choices: ${presetNames.join(", ")})`,
|
|
68
90
|
),
|
|
69
91
|
);
|
|
70
92
|
|
|
@@ -141,20 +163,51 @@ const counterFor = (plan: AuditPlan, logging: LogLevel.LogLevel) => {
|
|
|
141
163
|
};
|
|
142
164
|
};
|
|
143
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The status line for what a run does before its plan: walking the checkout,
|
|
168
|
+
* reading files, planning. On a terminal it is one line of stderr, redrawn at
|
|
169
|
+
* most every 100 ms and cut to the terminal's width; anywhere else, and at
|
|
170
|
+
* debug or below, whose log lines would land in it, it shows nothing.
|
|
171
|
+
*/
|
|
172
|
+
const statusLayer = Layer.effect(Status)(
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const logging = yield* References.MinimumLogLevel;
|
|
175
|
+
if (process.stderr.isTTY !== true || !LogLevel.isGreaterThan(logging, "Debug")) {
|
|
176
|
+
return Status.defaultValue();
|
|
177
|
+
}
|
|
178
|
+
let drawn = 0;
|
|
179
|
+
let shown = false;
|
|
180
|
+
return {
|
|
181
|
+
show: (text: string) =>
|
|
182
|
+
Effect.sync(() => {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (now - drawn < 100) return;
|
|
185
|
+
drawn = now;
|
|
186
|
+
shown = true;
|
|
187
|
+
// A terminal that reports no width, as some report 0, gets 80 columns.
|
|
188
|
+
const width = (process.stderr.columns || 80) - 1;
|
|
189
|
+
process.stderr.write(`\r\u001b[2K${text.slice(0, width)}`);
|
|
190
|
+
}),
|
|
191
|
+
clear: Effect.sync(() => {
|
|
192
|
+
if (shown) process.stderr.write("\r\u001b[2K");
|
|
193
|
+
shown = false;
|
|
194
|
+
drawn = 0;
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
144
200
|
const force = Flag.boolean("force").pipe(
|
|
145
201
|
Flag.withDefault(false),
|
|
146
202
|
Flag.withDescription("Overwrite existing scaffold files."),
|
|
147
203
|
);
|
|
148
204
|
|
|
149
|
-
const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
|
|
150
|
-
Option.isSome(flag) ? [flag.value] : [];
|
|
151
|
-
|
|
152
205
|
export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
|
|
153
206
|
Layer.mergeAll(
|
|
154
207
|
SourceWalkerLive(filter),
|
|
155
208
|
AuditCacheLive,
|
|
156
209
|
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
157
|
-
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
210
|
+
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
|
|
158
211
|
|
|
159
212
|
/** `adhere lint`: the audit. */
|
|
160
213
|
export const lintCommand = Command.make(
|
|
@@ -200,7 +253,7 @@ export const lintCommand = Command.make(
|
|
|
200
253
|
Command.provide((input) =>
|
|
201
254
|
auditLayer(
|
|
202
255
|
{
|
|
203
|
-
presets:
|
|
256
|
+
presets: input.preset,
|
|
204
257
|
threshold: Option.getOrUndefined(input.threshold),
|
|
205
258
|
rpm: Option.getOrUndefined(input.rpm),
|
|
206
259
|
},
|
|
@@ -214,7 +267,7 @@ export const validateLayer = (overrides: Overrides) =>
|
|
|
214
267
|
Layer.merge(
|
|
215
268
|
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
216
269
|
AuditCacheLive,
|
|
217
|
-
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
270
|
+
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
|
|
218
271
|
|
|
219
272
|
/**
|
|
220
273
|
* `adhere validate`: everything `lint` loads, then how each rule's wording
|
|
@@ -250,7 +303,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
250
303
|
Command.withDescription(
|
|
251
304
|
"Load the config, rule files, and presets the way lint does, check each rule's wording against the README's rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
252
305
|
),
|
|
253
|
-
Command.provide((input) => validateLayer({ presets:
|
|
306
|
+
Command.provide((input) => validateLayer({ presets: input.preset })),
|
|
254
307
|
);
|
|
255
308
|
|
|
256
309
|
/** `adhere init`: the scaffold. */
|
package/src/contradictions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RuleEntry, shownId } from "#rules.ts";
|
|
2
2
|
import { Jev, type Pair } from "#services/Jev.ts";
|
|
3
3
|
import { Effect } from "effect";
|
|
4
4
|
|
|
@@ -69,7 +69,9 @@ export const findContradictions = Effect.fn("findContradictions")(function* (
|
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** Where a rule lives: a preset's names the preset in its id; the project's own rule names its file. */
|
|
73
|
+
const locationOf = (entry: RuleEntry): string =>
|
|
74
|
+
entry.preset === undefined ? `${entry.id} (${entry.file ?? entry.scope})` : shownId(entry);
|
|
73
75
|
|
|
74
76
|
export const formatContradictions = (contradictions: ReadonlyArray<Contradiction>): string => {
|
|
75
77
|
if (contradictions.length === 0) {
|
package/src/mechanical.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Rule } from "#config.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RuleEntry, shownId } from "#rules.ts";
|
|
3
3
|
import { AuditCache, sha256, type Tally } from "#services/AuditCache.ts";
|
|
4
4
|
import { linterQuestion } from "#services/Jev.ts";
|
|
5
5
|
import { Effect } from "effect";
|
|
@@ -76,7 +76,7 @@ export const formatLinterCheck = (
|
|
|
76
76
|
: [
|
|
77
77
|
`${flagged.length} ${flagged.length === 1 ? "rule" : "rules"} flagged by the linter check:`,
|
|
78
78
|
...flagged.flatMap((one) => [
|
|
79
|
-
` ${one.entry
|
|
79
|
+
` ${shownId(one.entry)}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
|
|
80
80
|
` ${adviceOf(one)}`,
|
|
81
81
|
]),
|
|
82
82
|
]),
|
package/src/presets.ts
CHANGED
|
@@ -57,4 +57,7 @@ export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
|
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
/** The directory each whole preset reads, for checking `topics` against it. */
|
|
60
|
+
/** The whole preset a name belongs to: effect, for effect and for effect/basics alike. */
|
|
61
|
+
export const wholePresetOf = (name: PresetName): string => name.split("/")[0] ?? name;
|
|
62
|
+
|
|
60
63
|
export const topicsOf = (preset: WholePreset): ReadonlyArray<string> => topics[preset];
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev's price for input tokens, in dollars per million, by the name a config
|
|
3
|
+
* sends as its model. Output tokens are free. From TypeSafe AI's models page,
|
|
4
|
+
* https://docs.typesafe.ai/models, on 2026-09-25, when jev-latest and
|
|
5
|
+
* jev-preview both pointed to jev-1.13.0.
|
|
6
|
+
*/
|
|
7
|
+
const DOLLARS_PER_MILLION: Readonly<Record<string, number>> = {
|
|
8
|
+
"jev-1.13.0": 0.042,
|
|
9
|
+
"jev-latest": 0.042,
|
|
10
|
+
"jev-preview": 0.042,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The model's price per million input tokens, when adhere knows it. */
|
|
14
|
+
export const priceOf = (model: string): number | undefined => DOLLARS_PER_MILLION[model];
|
package/src/rules.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
SKIPPED_DIRECTORIES,
|
|
7
7
|
} from "#config.ts";
|
|
8
8
|
import { parseRuleMarkdown } from "#markdown.ts";
|
|
9
|
+
import { walkFiles } from "#walk.ts";
|
|
10
|
+
import { clearingStatus, countOf, Status } from "#services/Status.ts";
|
|
9
11
|
import { Effect, FileSystem, Path, Record } from "effect";
|
|
10
12
|
|
|
11
13
|
const refused = (directory: string) => (problem: { readonly message: string }) =>
|
|
@@ -18,10 +20,19 @@ export interface RuleEntry {
|
|
|
18
20
|
readonly rule: Rule;
|
|
19
21
|
readonly scope: string;
|
|
20
22
|
readonly file?: string;
|
|
21
|
-
/** The built-in
|
|
23
|
+
/** The built-in preset the rule came from, by its whole name (effect for effect/basics), when it is not the project's own. */
|
|
22
24
|
readonly preset?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A rule's id as output shows it: a preset's rule with the preset's name
|
|
29
|
+
* first, as in effect/basics/external-calls-are-resilient, so a report that
|
|
30
|
+
* mixes presets with the project's own rules says where each came from. The
|
|
31
|
+
* project's own rules show their `.adhere/` ids as they are.
|
|
32
|
+
*/
|
|
33
|
+
export const shownId = (entry: { readonly id: RuleId; readonly preset?: string }): string =>
|
|
34
|
+
entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`;
|
|
35
|
+
|
|
25
36
|
export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
26
37
|
|
|
27
38
|
const ADHERE_SEGMENT = ".adhere";
|
|
@@ -36,6 +47,21 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
|
|
|
36
47
|
|
|
37
48
|
const isMarkdown = (path: string): boolean => path.endsWith(".md");
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Whether the rule walk reads into a directory: on the way to a `.adhere/`,
|
|
52
|
+
* not a skipped tree or `.git`; inside one, anything but its cache, since a
|
|
53
|
+
* directory there such as `.adhere/e2e/` is a rule topic.
|
|
54
|
+
*/
|
|
55
|
+
const entersForRules = (relative: string): boolean => {
|
|
56
|
+
const segments = segmentsOf(relative);
|
|
57
|
+
const name = segments.at(-1) ?? "";
|
|
58
|
+
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
59
|
+
if (adhere >= 0 && adhere < segments.length - 1) {
|
|
60
|
+
return !(adhere === segments.length - 2 && name === CACHE_SEGMENT);
|
|
61
|
+
}
|
|
62
|
+
return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
|
|
63
|
+
};
|
|
64
|
+
|
|
39
65
|
const isNestedRuleFile = (relative: string): boolean => {
|
|
40
66
|
const segments = segmentsOf(relative);
|
|
41
67
|
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
@@ -108,10 +134,15 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
|
|
|
108
134
|
export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
|
|
109
135
|
const fs = yield* FileSystem.FileSystem;
|
|
110
136
|
const path = yield* Path.Path;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
137
|
+
const status = yield* Status;
|
|
138
|
+
const entries = yield* clearingStatus(
|
|
139
|
+
walkFiles(fs, path, root, entersForRules, (directories) =>
|
|
140
|
+
status.show(
|
|
141
|
+
`Looking for .adhere/ rules: ${countOf(directories, "directory", "directories")} read`,
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
).pipe(Effect.mapError(refused(root)));
|
|
145
|
+
const files = entries.filter(isNestedRuleFile);
|
|
115
146
|
return yield* Effect.forEach(files, (relative) =>
|
|
116
147
|
Effect.gen(function* () {
|
|
117
148
|
const file = joinPath(path, root, segmentsOf(relative));
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type ResolvedConfig,
|
|
12
12
|
resolveConfig,
|
|
13
13
|
} from "#config.ts";
|
|
14
|
-
import { type PresetName, presetOf } from "#presets.ts";
|
|
14
|
+
import { type PresetName, presetOf, wholePresetOf } from "#presets.ts";
|
|
15
15
|
import {
|
|
16
16
|
applicableRules,
|
|
17
17
|
globalRuleSet,
|
|
@@ -95,7 +95,10 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
95
95
|
).pipe(Effect.map(Record.fromEntries));
|
|
96
96
|
const resolved = resolveConfig({ ...config, rules }, overrides, registry);
|
|
97
97
|
const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
|
|
98
|
-
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
98
|
+
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
99
|
+
...entry,
|
|
100
|
+
preset: wholePresetOf(name as PresetName),
|
|
101
|
+
})),
|
|
99
102
|
);
|
|
100
103
|
yield* Effect.logDebug(
|
|
101
104
|
`config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
|
|
@@ -45,13 +45,11 @@ export const Tally = Schema.Struct({
|
|
|
45
45
|
export interface Tally extends Schema.Schema.Type<typeof Tally> {}
|
|
46
46
|
const decodeTally = Schema.decodeUnknownOption(Schema.fromJsonString(Tally));
|
|
47
47
|
|
|
48
|
-
/** What a run that read every file
|
|
48
|
+
/** What a run that read every file tells the cache. */
|
|
49
49
|
export interface Live {
|
|
50
|
-
/** The hash of each file's content. */
|
|
50
|
+
/** The hash of each file's content, too long to judge or not. */
|
|
51
51
|
readonly hashes: ReadonlySet<string>;
|
|
52
|
-
/** The
|
|
53
|
-
readonly fingerprints: ReadonlySet<string>;
|
|
54
|
-
/** The key of each rule's tally. */
|
|
52
|
+
/** The key of each of the run's rules' tallies, to carry over from the layout before content keys. */
|
|
55
53
|
readonly tallies: ReadonlySet<string>;
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -84,9 +82,11 @@ export class AuditCache extends Context.Service<
|
|
|
84
82
|
readonly tally: (key: string) => Effect.Effect<Tally | undefined>;
|
|
85
83
|
readonly putTally: (key: string, tally: Tally) => Effect.Effect<void>;
|
|
86
84
|
/**
|
|
87
|
-
* Deletes
|
|
88
|
-
* content and per tally.
|
|
89
|
-
*
|
|
85
|
+
* Deletes the answers about content no file has, and folds the rest into
|
|
86
|
+
* one file per content and per tally. Answers to rules the run left out
|
|
87
|
+
* stay, as do their tallies: a run with other presets or rules asks them.
|
|
88
|
+
* Only after a run that read every file: it cannot tell what content the
|
|
89
|
+
* files it did not read have. Succeeds with the files deleted.
|
|
90
90
|
*/
|
|
91
91
|
readonly prune: (live: Live) => Effect.Effect<number>;
|
|
92
92
|
}
|
|
@@ -275,10 +275,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
|
|
|
275
275
|
// Folding replaces or removes the group being visited, which a Map's iteration allows.
|
|
276
276
|
for (const group of (yield* indexes.files).keys()) {
|
|
277
277
|
const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
|
|
278
|
-
const answers =
|
|
279
|
-
unionOf(entries.map((entry) => entry.answers)),
|
|
280
|
-
(_, fingerprint) => live.fingerprints.has(fingerprint),
|
|
281
|
-
);
|
|
278
|
+
const answers = unionOf(entries.map((entry) => entry.answers));
|
|
282
279
|
deleted += yield* fold(
|
|
283
280
|
"files",
|
|
284
281
|
group,
|
|
@@ -286,7 +283,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
|
|
|
286
283
|
);
|
|
287
284
|
}
|
|
288
285
|
for (const key of (yield* indexes.tallies).keys()) {
|
|
289
|
-
const tallies =
|
|
286
|
+
const tallies = yield* readGroup("tallies", key, decodeTally);
|
|
290
287
|
deleted += yield* fold(
|
|
291
288
|
"tallies",
|
|
292
289
|
key,
|
package/src/services/Jev.ts
CHANGED
|
@@ -344,6 +344,23 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
344
344
|
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
345
345
|
};
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The requests judging `rules` over a file takes, and about how many input
|
|
349
|
+
* tokens they carry, by `tokensOf`: the judge body, split to fit, as `judge`
|
|
350
|
+
* sends it, each request with the file again.
|
|
351
|
+
*/
|
|
352
|
+
export const judgeLoad = (
|
|
353
|
+
lines: Lines,
|
|
354
|
+
rules: Rules,
|
|
355
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
356
|
+
): { readonly requests: number; readonly tokens: number } => {
|
|
357
|
+
const requests = requestsOf(judgeBody("", lines, rules, sampled));
|
|
358
|
+
return {
|
|
359
|
+
requests: requests.length,
|
|
360
|
+
tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
347
364
|
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
348
365
|
export const judgeRequests = (
|
|
349
366
|
lines: Lines,
|
|
@@ -3,6 +3,8 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
|
3
3
|
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
4
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
5
5
|
import { WalkUnavailable } from "#models/Audit.ts";
|
|
6
|
+
import { walkFiles } from "#walk.ts";
|
|
7
|
+
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
6
8
|
|
|
7
9
|
/** Walks the repository's TypeScript source, skipping what is not ours to read. */
|
|
8
10
|
export class SourceWalker extends Context.Service<
|
|
@@ -63,9 +65,15 @@ const isScannable = (file: string, self: string): boolean =>
|
|
|
63
65
|
const refused = (problem: { readonly message: string }): WalkUnavailable =>
|
|
64
66
|
WalkUnavailable.make({ message: problem.message });
|
|
65
67
|
|
|
66
|
-
/**
|
|
68
|
+
/** The walk yields paths relative to the root it read: root them. */
|
|
67
69
|
const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
|
|
68
70
|
|
|
71
|
+
/** Whether the walk reads into a directory: never a skipped tree, `.adhere/`, or `.git`. */
|
|
72
|
+
const entersForSource = (relative: string): boolean => {
|
|
73
|
+
const name = relative.split("/").at(-1) ?? "";
|
|
74
|
+
return !(SKIPPED_DIRECTORIES.has(name) || name === ADHERE_DIRECTORY || name === ".git");
|
|
75
|
+
};
|
|
76
|
+
|
|
69
77
|
/** A test on a path relative to the root being read: whether the run wants it. */
|
|
70
78
|
type Keep = (relative: string) => boolean;
|
|
71
79
|
|
|
@@ -76,20 +84,27 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
|
|
|
76
84
|
paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
|
|
77
85
|
).filter((file) => isScannable(file, self));
|
|
78
86
|
|
|
79
|
-
/** One root's
|
|
80
|
-
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
87
|
+
/** One root's files, filtered to what the audit reads. `label` names the root on the status line. */
|
|
88
|
+
const listRoot = Effect.fn("SourceWalker.listRoot")(function* (
|
|
89
|
+
fs: FileSystem.FileSystem,
|
|
90
|
+
path: Path.Path,
|
|
91
|
+
root: string,
|
|
92
|
+
label: string,
|
|
93
|
+
self: string,
|
|
94
|
+
keep: Keep,
|
|
95
|
+
) {
|
|
96
|
+
const status = yield* Status;
|
|
97
|
+
const paths = yield* walkFiles(fs, path, root, entersForSource, (directories, found) =>
|
|
98
|
+
status.show(
|
|
99
|
+
`Listing ${label}: ${countOf(directories, "directory", "directories")}, ${countOf(found, "file", "files")}`,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
const files = scannable(root, paths, self, keep);
|
|
103
|
+
yield* Effect.logDebug(
|
|
104
|
+
`listed ${root}: ${paths.length} files, ${files.length} source files to read`,
|
|
105
|
+
);
|
|
106
|
+
return files;
|
|
107
|
+
});
|
|
93
108
|
|
|
94
109
|
/** One scannable path, read into lines. */
|
|
95
110
|
const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem, path: string) =>
|
|
@@ -99,14 +114,42 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
|
|
|
99
114
|
})).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
|
|
100
115
|
);
|
|
101
116
|
|
|
102
|
-
/**
|
|
103
|
-
const readEach = (
|
|
104
|
-
|
|
117
|
+
/** Reads a root's paths, several at a time, keeping their order, and counts them on the status line. */
|
|
118
|
+
const readEach = Effect.fn("SourceWalker.readEach")(function* (
|
|
119
|
+
fs: FileSystem.FileSystem,
|
|
120
|
+
label: string,
|
|
121
|
+
paths: ReadonlyArray<string>,
|
|
122
|
+
) {
|
|
123
|
+
const status = yield* Status;
|
|
124
|
+
let read = 0;
|
|
125
|
+
return yield* Effect.forEach(
|
|
126
|
+
paths,
|
|
127
|
+
(path: string) =>
|
|
128
|
+
readFile(fs, path).pipe(
|
|
129
|
+
Effect.tap(() => {
|
|
130
|
+
read += 1;
|
|
131
|
+
return status.show(
|
|
132
|
+
`Reading ${label}: ${counted(read)} of ${countOf(paths.length, "file", "files")}`,
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
{ concurrency: 16 },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
105
139
|
|
|
106
|
-
/** One root's files, in
|
|
140
|
+
/** One root's files, in path order: read every scannable path. */
|
|
107
141
|
const readRoot = Effect.fn("SourceWalker.readRoot")(
|
|
108
|
-
(
|
|
109
|
-
|
|
142
|
+
(
|
|
143
|
+
fs: FileSystem.FileSystem,
|
|
144
|
+
path: Path.Path,
|
|
145
|
+
root: string,
|
|
146
|
+
label: string,
|
|
147
|
+
self: string,
|
|
148
|
+
keep: Keep,
|
|
149
|
+
) =>
|
|
150
|
+
Effect.flatMap(listRoot(fs, path, root, label, self, keep), (paths) =>
|
|
151
|
+
readEach(fs, label, paths),
|
|
152
|
+
),
|
|
110
153
|
);
|
|
111
154
|
|
|
112
155
|
/**
|
|
@@ -125,8 +168,9 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
125
168
|
});
|
|
126
169
|
|
|
127
170
|
/**
|
|
128
|
-
* The real walker:
|
|
129
|
-
* each file read and split into lines. Tests substitute their
|
|
171
|
+
* The real walker: the roots walked a directory at a time, skipped trees
|
|
172
|
+
* left unread, each file read and split into lines. Tests substitute their
|
|
173
|
+
* own tree.
|
|
130
174
|
* The scan root is the current working directory, so the audit reads the
|
|
131
175
|
* repository it is run in.
|
|
132
176
|
*/
|
|
@@ -140,11 +184,19 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
140
184
|
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
141
185
|
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
142
186
|
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
143
|
-
const trees = yield*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
187
|
+
const trees = yield* clearingStatus(
|
|
188
|
+
Effect.forEach(dirs, (dir) =>
|
|
189
|
+
readRoot(
|
|
190
|
+
fs,
|
|
191
|
+
path,
|
|
192
|
+
path.join(root, dir),
|
|
193
|
+
dir === "." ? "the working directory" : `${dir}/`,
|
|
194
|
+
self,
|
|
195
|
+
(relative) =>
|
|
196
|
+
// Filter patterns are relative to the working directory, not the tree read.
|
|
197
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
|
|
198
|
+
).pipe(Effect.mapError(refused)),
|
|
199
|
+
),
|
|
148
200
|
);
|
|
149
201
|
return trees.flat();
|
|
150
202
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface StatusLine {
|
|
4
|
+
/** Shows what the run is doing now, in place of what was shown before. */
|
|
5
|
+
readonly show: (text: string) => Effect.Effect<void>;
|
|
6
|
+
/** Takes the line away, before anything else is written. */
|
|
7
|
+
readonly clear: Effect.Effect<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a run is doing before it has anything to report: walking the
|
|
12
|
+
* checkout, reading files, planning. The CLI draws it as one line on
|
|
13
|
+
* stderr; wherever nothing provides it, as in tests, it shows nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const Status = Context.Reference<StatusLine>("@drkmttr/adhere/services/Status", {
|
|
16
|
+
defaultValue: () => ({ show: () => Effect.void, clear: Effect.void }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** `effect`, with the status line cleared when it ends, however it ends. */
|
|
20
|
+
export const clearingStatus = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const status = yield* Status;
|
|
23
|
+
return yield* Effect.ensuring(effect, status.clear);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** A count as the status line shows it: 10,333. */
|
|
27
|
+
export const counted = (n: number): string => n.toLocaleString("en-US");
|
|
28
|
+
|
|
29
|
+
/** A count and its noun: 1 directory, 2,679 directories. */
|
|
30
|
+
export const countOf = (n: number, one: string, many: string): string =>
|
|
31
|
+
`${counted(n)} ${n === 1 ? one : many}`;
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Effect, type FileSystem, type Path, type PlatformError } from "effect";
|
|
2
|
+
|
|
3
|
+
/** A directory entry, as the walk sorts it: a file to list, a directory to read, or neither. */
|
|
4
|
+
type Entry =
|
|
5
|
+
| { readonly kind: "file"; readonly relative: string }
|
|
6
|
+
| { readonly kind: "directory"; readonly relative: string }
|
|
7
|
+
| { readonly kind: "skipped" };
|
|
8
|
+
|
|
9
|
+
const skipped: Entry = { kind: "skipped" };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every file under `root`, each as a path relative to it with `/` between
|
|
13
|
+
* segments, sorted. `enter` says, of each directory below the root by its
|
|
14
|
+
* relative path, whether to read into it, so a walk never lists what it would
|
|
15
|
+
* throw away.
|
|
16
|
+
*
|
|
17
|
+
* A directory reached through a symbolic link is never entered: pnpm's
|
|
18
|
+
* `node_modules` links packages into one another, and following the links
|
|
19
|
+
* lists every path through the dependency graph. A recursive `readdir` does
|
|
20
|
+
* follow them; on alchemy's checkout it passed 160 GB before the kernel
|
|
21
|
+
* killed it. A symbolic link to a file is listed like the file, and a link
|
|
22
|
+
* to nothing is left out.
|
|
23
|
+
*
|
|
24
|
+
* `progress` hears, as each directory is read, how many have been and how
|
|
25
|
+
* many files they held.
|
|
26
|
+
*/
|
|
27
|
+
export const walkFiles = Effect.fn("walkFiles")(function* (
|
|
28
|
+
fs: FileSystem.FileSystem,
|
|
29
|
+
path: Path.Path,
|
|
30
|
+
root: string,
|
|
31
|
+
enter: (relative: string) => boolean,
|
|
32
|
+
progress: (directories: number, files: number) => Effect.Effect<void> = () => Effect.void,
|
|
33
|
+
) {
|
|
34
|
+
const entryOf = (relative: string): Effect.Effect<Entry, PlatformError.PlatformError> => {
|
|
35
|
+
const full = path.join(root, relative);
|
|
36
|
+
return fs.stat(full).pipe(
|
|
37
|
+
Effect.flatMap((info): Effect.Effect<Entry> => {
|
|
38
|
+
if (info.type !== "Directory") return Effect.succeed({ kind: "file", relative });
|
|
39
|
+
if (!enter(relative)) return Effect.succeed(skipped);
|
|
40
|
+
// Only a link can be read as one, so a failed read is a real directory.
|
|
41
|
+
return fs.readLink(full).pipe(
|
|
42
|
+
Effect.match({
|
|
43
|
+
onFailure: (): Entry => ({ kind: "directory", relative }),
|
|
44
|
+
onSuccess: (): Entry => skipped,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}),
|
|
48
|
+
Effect.catchIf(
|
|
49
|
+
(problem) => problem.reason._tag === "NotFound",
|
|
50
|
+
() => Effect.succeed(skipped),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const files: Array<string> = [];
|
|
56
|
+
let directories = 0;
|
|
57
|
+
let level: ReadonlyArray<string> = [""];
|
|
58
|
+
while (level.length > 0) {
|
|
59
|
+
const next: Array<string> = [];
|
|
60
|
+
yield* Effect.forEach(
|
|
61
|
+
level,
|
|
62
|
+
(directory) =>
|
|
63
|
+
fs.readDirectory(directory === "" ? root : path.join(root, directory)).pipe(
|
|
64
|
+
Effect.flatMap((names) =>
|
|
65
|
+
Effect.forEach(
|
|
66
|
+
names,
|
|
67
|
+
(name) => entryOf(directory === "" ? name : `${directory}/${name}`),
|
|
68
|
+
{ concurrency: 16 },
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
Effect.flatMap((entries) => {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.kind === "file") files.push(entry.relative);
|
|
74
|
+
else if (entry.kind === "directory") next.push(entry.relative);
|
|
75
|
+
}
|
|
76
|
+
directories += 1;
|
|
77
|
+
return progress(directories, files.length);
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
{ concurrency: 8, discard: true },
|
|
81
|
+
);
|
|
82
|
+
level = next;
|
|
83
|
+
}
|
|
84
|
+
return files.sort();
|
|
85
|
+
});
|
package/src/wording.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Example, examplesOf, type Rule } from "#config.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RuleEntry, shownId } from "#rules.ts";
|
|
3
3
|
|
|
4
4
|
type Word = Example["word"];
|
|
5
5
|
|
|
@@ -83,7 +83,7 @@ export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): Readon
|
|
|
83
83
|
: [
|
|
84
84
|
`${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
|
|
85
85
|
...strays.flatMap(({ entry, straying }) => [
|
|
86
|
-
` ${entry
|
|
86
|
+
` ${shownId(entry)}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
|
|
87
87
|
...straying.map((one) => ` ${sentenceOf(one)}`),
|
|
88
88
|
]),
|
|
89
89
|
`Rule writing tips: ${TIPS_URL}`,
|
package/src/workflows/audit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
JevUnavailable,
|
|
10
10
|
type Judged,
|
|
11
11
|
judgeQuestion,
|
|
12
|
+
judgeLoad,
|
|
12
13
|
judgeRequests,
|
|
13
14
|
linterQuestion,
|
|
14
15
|
locateRequests,
|
|
@@ -16,12 +17,16 @@ import {
|
|
|
16
17
|
tokensOf,
|
|
17
18
|
} from "#services/Jev.ts";
|
|
18
19
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
20
|
+
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
19
21
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
22
|
+
import { priceOf } from "#pricing.ts";
|
|
20
23
|
import { applicableRules } from "#rules.ts";
|
|
21
24
|
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
22
25
|
|
|
23
26
|
export interface Finding {
|
|
24
27
|
readonly rule: RuleId;
|
|
28
|
+
/** The preset the rule came from, when it is not the project's own. */
|
|
29
|
+
readonly preset?: string;
|
|
25
30
|
readonly description: string;
|
|
26
31
|
readonly examples: Examples;
|
|
27
32
|
readonly file: string;
|
|
@@ -59,6 +64,8 @@ interface PreparedRule {
|
|
|
59
64
|
readonly rule: Rule;
|
|
60
65
|
readonly fingerprint: string;
|
|
61
66
|
readonly threshold: number;
|
|
67
|
+
/** The preset the rule came from, when it is not the project's own. */
|
|
68
|
+
readonly preset?: string;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
const fileResult = (
|
|
@@ -107,6 +114,10 @@ export interface AuditPlan {
|
|
|
107
114
|
readonly deferred: number;
|
|
108
115
|
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
109
116
|
readonly requests: number;
|
|
117
|
+
/** About how many input tokens those requests carry, which Jev charges for. */
|
|
118
|
+
readonly tokens: number;
|
|
119
|
+
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
120
|
+
readonly price?: number;
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
/** A file finished, for a progress counter: the requests it took and the findings it has. */
|
|
@@ -174,13 +185,15 @@ export const planAudit = (
|
|
|
174
185
|
|
|
175
186
|
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
176
187
|
const rules = configuredRules(file.path);
|
|
188
|
+
// Hashed even when skipped, so a prune keeps what other runs know of its content.
|
|
189
|
+
const hash = yield* sha256(file.lines.join("\n"));
|
|
177
190
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
178
191
|
if (!fits(file.lines, rules)) {
|
|
179
192
|
yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
|
|
180
193
|
return {
|
|
181
194
|
file,
|
|
182
195
|
skipped: true,
|
|
183
|
-
hash
|
|
196
|
+
hash,
|
|
184
197
|
prepared: {},
|
|
185
198
|
kept: {},
|
|
186
199
|
pending: {},
|
|
@@ -191,13 +204,19 @@ export const planAudit = (
|
|
|
191
204
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
192
205
|
Object.entries(rules),
|
|
193
206
|
([id, rule]) =>
|
|
194
|
-
Effect.map(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
208
|
+
const preset = presetOfRule.get(rule);
|
|
209
|
+
return [
|
|
210
|
+
id,
|
|
211
|
+
{
|
|
212
|
+
rule,
|
|
213
|
+
fingerprint,
|
|
214
|
+
threshold: rule.threshold ?? config.threshold,
|
|
215
|
+
...(preset === undefined ? {} : { preset }),
|
|
216
|
+
},
|
|
217
|
+
] as const;
|
|
218
|
+
}),
|
|
199
219
|
).pipe(Effect.map(Record.fromEntries));
|
|
200
|
-
const hash = yield* sha256(file.lines.join("\n"));
|
|
201
220
|
const answers = yield* cache.get(hash, file.path);
|
|
202
221
|
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
203
222
|
const answer = answers[fingerprint];
|
|
@@ -219,6 +238,12 @@ export const planAudit = (
|
|
|
219
238
|
} satisfies FilePlan;
|
|
220
239
|
});
|
|
221
240
|
|
|
241
|
+
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
242
|
+
const presetOfRule = new Map(
|
|
243
|
+
(config.scopedRules ?? []).flatMap((entry) =>
|
|
244
|
+
entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
|
|
245
|
+
),
|
|
246
|
+
);
|
|
222
247
|
// Preset rules are not the project's to change, so the linter check leaves them out.
|
|
223
248
|
const presetRules = new Set(
|
|
224
249
|
(config.scopedRules ?? []).flatMap((entry) =>
|
|
@@ -281,13 +306,33 @@ export const planAudit = (
|
|
|
281
306
|
});
|
|
282
307
|
|
|
283
308
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
const status = yield* Status;
|
|
310
|
+
let done = 0;
|
|
311
|
+
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
312
|
+
Effect.forEach(
|
|
313
|
+
sorted,
|
|
314
|
+
(file) =>
|
|
315
|
+
planFile(file).pipe(
|
|
316
|
+
Effect.tap(() => {
|
|
317
|
+
done += 1;
|
|
318
|
+
return status.show(
|
|
319
|
+
`Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
|
|
320
|
+
);
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
{ concurrency: 8 },
|
|
324
|
+
),
|
|
325
|
+
);
|
|
287
326
|
const limited =
|
|
288
327
|
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
289
328
|
const planned = yield* withSamples(limited);
|
|
290
329
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
330
|
+
const loads = judged.map((plan) =>
|
|
331
|
+
isEmpty(plan.pending)
|
|
332
|
+
? { requests: 0, tokens: 0 }
|
|
333
|
+
: judgeLoad(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
334
|
+
);
|
|
335
|
+
const price = priceOf(config.model);
|
|
291
336
|
const total = (count: (plan: FilePlan) => number) =>
|
|
292
337
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
293
338
|
return {
|
|
@@ -297,11 +342,9 @@ export const planAudit = (
|
|
|
297
342
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
298
343
|
skipped: planned.length - judged.length,
|
|
299
344
|
deferred: total((plan) => plan.deferred),
|
|
300
|
-
requests:
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
: judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
304
|
-
),
|
|
345
|
+
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
346
|
+
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
347
|
+
...(price === undefined ? {} : { price }),
|
|
305
348
|
};
|
|
306
349
|
});
|
|
307
350
|
|
|
@@ -401,6 +444,7 @@ export const executeAudit = (
|
|
|
401
444
|
? [
|
|
402
445
|
{
|
|
403
446
|
rule: id,
|
|
447
|
+
...(k.preset === undefined ? {} : { preset: k.preset }),
|
|
404
448
|
description: k.rule.description,
|
|
405
449
|
examples: examplesOf(k.rule),
|
|
406
450
|
file: file.path,
|
|
@@ -486,22 +530,21 @@ export const executeAudit = (
|
|
|
486
530
|
});
|
|
487
531
|
|
|
488
532
|
/**
|
|
489
|
-
* Deletes from the cache
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
533
|
+
* Deletes from the cache the answers about content no file has anymore.
|
|
534
|
+
* Answers to rules the run left out stay, since a run with other presets or
|
|
535
|
+
* rules asks them. Only after a run that read every file; a run narrowed by
|
|
536
|
+
* `--filter` cannot tell what content the files it left out have.
|
|
493
537
|
*/
|
|
494
538
|
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
495
539
|
const config = yield* AdhereConfig;
|
|
496
540
|
const cache = yield* AuditCache;
|
|
497
|
-
const
|
|
498
|
-
const prepared = read.flatMap((file) => Object.values(file.prepared));
|
|
541
|
+
const prepared = plan.files.flatMap((file) => Object.values(file.prepared));
|
|
499
542
|
const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
|
|
500
543
|
tallyKeyOf(config.model, rule),
|
|
501
544
|
);
|
|
502
545
|
const deleted = yield* cache.prune({
|
|
503
|
-
|
|
504
|
-
|
|
546
|
+
// A file too long to judge counts: a run with shorter questions judges it.
|
|
547
|
+
hashes: new Set(plan.files.map((file) => file.hash)),
|
|
505
548
|
tallies: new Set(tallies),
|
|
506
549
|
});
|
|
507
550
|
yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
|
package/src/workflows/format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Kind, tokenize } from "#highlight.ts";
|
|
2
|
+
import { shownId } from "#rules.ts";
|
|
2
3
|
import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -91,7 +92,7 @@ const header = (finding: Finding): Line => [
|
|
|
91
92
|
span(" "),
|
|
92
93
|
span("×", "error"),
|
|
93
94
|
span(" "),
|
|
94
|
-
span(finding.rule, "error"),
|
|
95
|
+
span(shownId({ id: finding.rule, preset: finding.preset }), "error"),
|
|
95
96
|
span(" ("),
|
|
96
97
|
span(finding.probability.toFixed(2), "probability"),
|
|
97
98
|
span("): "),
|
|
@@ -224,6 +225,41 @@ const durationAt = (requests: number, rpm: number): string => {
|
|
|
224
225
|
: counted(Math.round(seconds / 60), "minute", "minutes");
|
|
225
226
|
};
|
|
226
227
|
|
|
228
|
+
/** A number to one decimal place, without a trailing .0: 3.2, 43. */
|
|
229
|
+
const tenths = (n: number): string => Number(n.toFixed(1)).toLocaleString("en-US");
|
|
230
|
+
|
|
231
|
+
/** A token count as the plan says it: 612, 41,000, 3.2 million, 43 million, 1.1 billion. */
|
|
232
|
+
const tokensSaid = (tokens: number): string =>
|
|
233
|
+
tokens >= 1e9
|
|
234
|
+
? `${tenths(tokens / 1e9)} billion`
|
|
235
|
+
: tokens >= 1e6
|
|
236
|
+
? `${tenths(tokens / 1e6)} million`
|
|
237
|
+
: tokens >= 1e3
|
|
238
|
+
? (Math.round(tokens / 1e3) * 1e3).toLocaleString("en-US")
|
|
239
|
+
: tokens.toLocaleString("en-US");
|
|
240
|
+
|
|
241
|
+
/** An estimated cost as the plan says it: under $0.01, about $0.14, about $1,234. */
|
|
242
|
+
const dollars = (amount: number): string =>
|
|
243
|
+
amount < 0.01
|
|
244
|
+
? "under $0.01"
|
|
245
|
+
: amount < 100
|
|
246
|
+
? `about $${amount.toFixed(2)}`
|
|
247
|
+
: `about $${Math.round(amount).toLocaleString("en-US")}`;
|
|
248
|
+
|
|
249
|
+
/** What judging the plan costs at the model's price, when adhere knows it. */
|
|
250
|
+
const costOf = (plan: AuditPlan): number | undefined =>
|
|
251
|
+
plan.price === undefined ? undefined : (plan.tokens / 1e6) * plan.price;
|
|
252
|
+
|
|
253
|
+
/** The plan's input tokens and their cost; locating findings comes on top, since it depends on the findings. */
|
|
254
|
+
const costLine = (plan: AuditPlan): string => {
|
|
255
|
+
const cost = costOf(plan);
|
|
256
|
+
const priced =
|
|
257
|
+
cost === undefined || plan.price === undefined
|
|
258
|
+
? ""
|
|
259
|
+
: `: ${dollars(cost)} at $${plan.price} per million`;
|
|
260
|
+
return `Those carry about ${tokensSaid(plan.tokens)} input tokens${priced}, and more for locating findings.`;
|
|
261
|
+
};
|
|
262
|
+
|
|
227
263
|
export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
|
|
228
264
|
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
229
265
|
const skipped =
|
|
@@ -260,12 +296,16 @@ export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyA
|
|
|
260
296
|
return [
|
|
261
297
|
found,
|
|
262
298
|
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
|
|
299
|
+
...(plan.tokens > 0 ? [costLine(plan)] : []),
|
|
263
300
|
];
|
|
264
301
|
};
|
|
265
302
|
|
|
266
303
|
/** The question before a run sends anything. */
|
|
267
|
-
export const sendQuestion = (plan: AuditPlan): string =>
|
|
268
|
-
|
|
304
|
+
export const sendQuestion = (plan: AuditPlan): string => {
|
|
305
|
+
const cost = costOf(plan);
|
|
306
|
+
const priced = cost === undefined || plan.tokens === 0 ? "" : `, ${dollars(cost)}`;
|
|
307
|
+
return `Send ${counted(plan.requests, "request", "requests")} to Jev${priced}?`;
|
|
308
|
+
};
|
|
269
309
|
|
|
270
310
|
/** A run's progress so far: every file it finished, with the requests and findings they added up to. */
|
|
271
311
|
export interface Progress {
|