@drkmttr/adhere 0.7.0 → 0.7.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 +18 -0
- package/package.json +6 -6
- package/src/cli.ts +69 -34
- package/src/config.ts +5 -0
- package/src/services/Jev.http.ts +27 -5
- package/src/services/SourceWalker.ts +47 -23
- package/src/workflows/audit.ts +105 -56
- package/src/workflows/format.ts +44 -5
package/README.md
CHANGED
|
@@ -75,6 +75,9 @@ adhere lint # audit the working directory
|
|
|
75
75
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
76
76
|
adhere lint --threshold 0.9 # replace the config's threshold; per-rule thresholds still apply
|
|
77
77
|
adhere lint --yes # send the requests without asking first
|
|
78
|
+
adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
|
|
79
|
+
adhere lint --rpm 30 # send at most 30 requests a minute
|
|
80
|
+
adhere lint --filter 'src/**' # read only the files a glob matches
|
|
78
81
|
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
79
82
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
80
83
|
adhere login # save a TypeSafe AI API key for later runs
|
|
@@ -104,6 +107,21 @@ the report goes to stdout. A request Jev refuses stops the run with the file,
|
|
|
104
107
|
the HTTP status, and Jev's reason. Files finished before it are cached, so
|
|
105
108
|
running again continues from there.
|
|
106
109
|
|
|
110
|
+
### Limiting a run
|
|
111
|
+
|
|
112
|
+
Three flags bound what a run does. `--limit <checks>` judges at most that many
|
|
113
|
+
checks, taken in path order; the rest wait, and since judgments are cached, the
|
|
114
|
+
next run with the same limit picks up where this one stopped. `--limit 0` shows
|
|
115
|
+
the plan and judges nothing. `--rpm <requests>` sends at most that many requests
|
|
116
|
+
to Jev a minute, evenly spaced, retries included, and the plan says about how
|
|
117
|
+
long they take. `--filter <glob>` reads only the files whose path from the
|
|
118
|
+
working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
|
|
119
|
+
more, and start a pattern with `!` to leave out what it matches. Together:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
|
|
123
|
+
```
|
|
124
|
+
|
|
107
125
|
### What gets read
|
|
108
126
|
|
|
109
127
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules 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.7.
|
|
71
|
-
"@drkmttr/adhere-darwin-x64": "0.7.
|
|
72
|
-
"@drkmttr/adhere-linux-arm64": "0.7.
|
|
73
|
-
"@drkmttr/adhere-linux-x64": "0.7.
|
|
74
|
-
"@drkmttr/adhere-win32-x64": "0.7.
|
|
70
|
+
"@drkmttr/adhere-darwin-arm64": "0.7.1",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.7.1",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.7.1",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.7.1",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.7.1"
|
|
75
75
|
}
|
|
76
76
|
}
|
package/src/cli.ts
CHANGED
|
@@ -68,6 +68,33 @@ const threshold = Flag.float("threshold").pipe(
|
|
|
68
68
|
),
|
|
69
69
|
);
|
|
70
70
|
|
|
71
|
+
const limit = Flag.integer("limit").pipe(
|
|
72
|
+
Flag.filter(
|
|
73
|
+
(checks) => checks >= 0,
|
|
74
|
+
(checks) => `--limit is a number of checks, 0 or more, not ${checks}.`,
|
|
75
|
+
),
|
|
76
|
+
Flag.optional,
|
|
77
|
+
Flag.withDescription(
|
|
78
|
+
"Judge at most this many checks. The rest wait for a later run, which picks up where this one stopped, since judgments are cached. --limit 0 shows the plan and judges nothing.",
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const rpm = Flag.integer("rpm").pipe(
|
|
83
|
+
Flag.filter(
|
|
84
|
+
(requests) => requests > 0,
|
|
85
|
+
(requests) => `--rpm is a number of requests a minute, more than 0, not ${requests}.`,
|
|
86
|
+
),
|
|
87
|
+
Flag.optional,
|
|
88
|
+
Flag.withDescription("Send at most this many requests to Jev a minute, evenly spaced."),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const filter = Flag.string("filter").pipe(
|
|
92
|
+
Flag.atLeast(0),
|
|
93
|
+
Flag.withDescription(
|
|
94
|
+
"Read only files whose path from the working directory matches this glob, such as 'src/**' or '**/*.service.ts'. Repeat it for more; a pattern starting with ! leaves out what it matches.",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
71
98
|
const yes = Flag.boolean("yes").pipe(
|
|
72
99
|
Flag.withAlias("y"),
|
|
73
100
|
Flag.withDefault(false),
|
|
@@ -114,53 +141,61 @@ const force = Flag.boolean("force").pipe(
|
|
|
114
141
|
const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
|
|
115
142
|
Option.isSome(flag) ? [flag.value] : [];
|
|
116
143
|
|
|
117
|
-
export const auditLayer = (overrides: Overrides) =>
|
|
144
|
+
export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
|
|
118
145
|
Layer.mergeAll(
|
|
119
|
-
SourceWalkerLive,
|
|
146
|
+
SourceWalkerLive(filter),
|
|
120
147
|
AuditCacheLive,
|
|
121
148
|
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
122
149
|
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
123
150
|
|
|
124
151
|
/** `adhere lint`: the audit. */
|
|
125
|
-
export const lintCommand = Command.make(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
export const lintCommand = Command.make(
|
|
153
|
+
"lint",
|
|
154
|
+
{ preset, threshold, yes, limit, rpm, filter },
|
|
155
|
+
(input) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const stdio = yield* Stdio.Stdio;
|
|
158
|
+
const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
|
|
159
|
+
const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
|
|
160
|
+
yield* toStderr(`${describePlan(plan, limits).join("\n")}\n`);
|
|
161
|
+
// A prompt draws on stdout, so it needs a terminal at both ends: none when
|
|
162
|
+
// the report is redirected, or in CI.
|
|
163
|
+
const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
|
|
164
|
+
if (plan.requests > 0 && interactive && !input.yes) {
|
|
165
|
+
const send = yield* Prompt.run(
|
|
166
|
+
Prompt.confirm({ message: sendQuestion(plan), initial: true }),
|
|
167
|
+
);
|
|
168
|
+
if (!send) {
|
|
169
|
+
yield* toStderr("Nothing sent.\n");
|
|
170
|
+
return yield* Cancelled.make({});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const counter = counterFor(plan);
|
|
174
|
+
yield* counter.start;
|
|
175
|
+
const result = yield* executeAudit(plan, counter.update).pipe(
|
|
176
|
+
Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
|
|
136
177
|
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
const color = yield* stdio.stdoutIsTerminal;
|
|
179
|
+
const path = yield* Path.Path;
|
|
180
|
+
// One write: separate Console.log calls have interleaved out of order here.
|
|
181
|
+
yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
|
|
182
|
+
if (result.findings.length > 0) {
|
|
183
|
+
yield* FindingsReported.make({ count: result.findings.length });
|
|
140
184
|
}
|
|
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
|
-
);
|
|
147
|
-
const color = yield* stdio.stdoutIsTerminal;
|
|
148
|
-
const path = yield* Path.Path;
|
|
149
|
-
// One write: separate Console.log calls have interleaved out of order here.
|
|
150
|
-
yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
|
|
151
|
-
if (result.findings.length > 0) {
|
|
152
|
-
yield* FindingsReported.make({ count: result.findings.length });
|
|
153
|
-
}
|
|
154
|
-
}),
|
|
185
|
+
}),
|
|
155
186
|
).pipe(
|
|
156
187
|
Command.withDescription(
|
|
157
188
|
"Audit the working directory against the rules in .adhere/ or a preset, judged by Jev.",
|
|
158
189
|
),
|
|
159
190
|
Command.provide((input) =>
|
|
160
|
-
auditLayer(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
191
|
+
auditLayer(
|
|
192
|
+
{
|
|
193
|
+
presets: chosenPresets(input.preset),
|
|
194
|
+
threshold: Option.getOrUndefined(input.threshold),
|
|
195
|
+
rpm: Option.getOrUndefined(input.rpm),
|
|
196
|
+
},
|
|
197
|
+
input.filter,
|
|
198
|
+
),
|
|
164
199
|
),
|
|
165
200
|
);
|
|
166
201
|
|
package/src/config.ts
CHANGED
|
@@ -148,6 +148,8 @@ export interface ResolvedConfig {
|
|
|
148
148
|
readonly threshold: number;
|
|
149
149
|
readonly rules: Rules;
|
|
150
150
|
readonly scopedRules?: RuleSet;
|
|
151
|
+
/** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
|
|
152
|
+
readonly rpm?: number;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/** Command-line values that apply on top of the config file. */
|
|
@@ -155,6 +157,8 @@ export interface Overrides {
|
|
|
155
157
|
readonly presets?: ReadonlyArray<PresetName>;
|
|
156
158
|
/** Replaces the config's global threshold. Per-rule thresholds still win. */
|
|
157
159
|
readonly threshold?: number;
|
|
160
|
+
/** Throttles requests to Jev to at most this many a minute. */
|
|
161
|
+
readonly rpm?: number;
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/** The presets a run applies, command-line ones first. */
|
|
@@ -183,6 +187,7 @@ export const resolveConfig = (
|
|
|
183
187
|
model: config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
184
188
|
threshold: overrides.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
185
189
|
rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
|
|
190
|
+
...(overrides.rpm === undefined ? {} : { rpm: overrides.rpm }),
|
|
186
191
|
};
|
|
187
192
|
};
|
|
188
193
|
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -19,9 +19,10 @@ import {
|
|
|
19
19
|
requestsOf,
|
|
20
20
|
type Rules,
|
|
21
21
|
} from "#services/Jev.ts";
|
|
22
|
-
import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
22
|
+
import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
23
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
24
24
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
25
|
+
import { RateLimiter } from "effect/unstable/persistence";
|
|
25
26
|
|
|
26
27
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
27
28
|
|
|
@@ -53,7 +54,9 @@ const isOverflow = (body: string): boolean =>
|
|
|
53
54
|
* the run with its status and what Jev said, since the status alone does not
|
|
54
55
|
* say why, and a request that got no answer refuses with the cause.
|
|
55
56
|
*/
|
|
56
|
-
const failed = (
|
|
57
|
+
const failed = (
|
|
58
|
+
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
59
|
+
) => {
|
|
57
60
|
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
58
61
|
return Effect.fail(refused(`System One request failed: ${problem.message}`));
|
|
59
62
|
}
|
|
@@ -79,8 +82,27 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
79
82
|
Effect.gen(function* () {
|
|
80
83
|
const config = yield* AdhereConfig;
|
|
81
84
|
const credentials = yield* Credentials;
|
|
82
|
-
const
|
|
83
|
-
|
|
85
|
+
const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
86
|
+
// `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
|
|
87
|
+
// burst each minute. Before the retries, so a retry waits its turn too.
|
|
88
|
+
type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
|
|
89
|
+
const paced: HttpClient.HttpClient.With<Failure> =
|
|
90
|
+
config.rpm === undefined
|
|
91
|
+
? // The same client, typed to fail the ways the throttled one can.
|
|
92
|
+
HttpClient.transformResponse(
|
|
93
|
+
http,
|
|
94
|
+
(response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
|
|
95
|
+
)
|
|
96
|
+
: http.pipe(
|
|
97
|
+
HttpClient.withRateLimiter({
|
|
98
|
+
limiter: yield* RateLimiter.RateLimiter,
|
|
99
|
+
key: "jev",
|
|
100
|
+
algorithm: "token-bucket",
|
|
101
|
+
limit: 1,
|
|
102
|
+
window: Duration.millis(60_000 / config.rpm),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const client = paced.pipe(
|
|
84
106
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
85
107
|
HttpClient.retryTransient({
|
|
86
108
|
schedule: Schedule.exponential("500 millis"),
|
|
@@ -172,4 +194,4 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
172
194
|
|
|
173
195
|
return Jev.of({ judge, locate, conflicts, contradicts });
|
|
174
196
|
}),
|
|
175
|
-
);
|
|
197
|
+
).pipe(Layer.provide(RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesGlob } from "node:path";
|
|
1
2
|
import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
2
3
|
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
3
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
@@ -31,6 +32,22 @@ export const isInSkippedTree = (relative: string): boolean =>
|
|
|
31
32
|
.split(/[\\/]/)
|
|
32
33
|
.some((segment) => segment === ADHERE_DIRECTORY || SKIPPED_DIRECTORIES.has(segment));
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether a path, relative to the working directory, passes a run's filter.
|
|
37
|
+
* A pattern is a glob; one starting with `!` leaves out what it matches. A
|
|
38
|
+
* path passes when no `!` pattern matches it and either another pattern does
|
|
39
|
+
* or there are none.
|
|
40
|
+
*/
|
|
41
|
+
export const passesFilter = (relative: string, patterns: ReadonlyArray<string>): boolean => {
|
|
42
|
+
const path = relative.replaceAll("\\", "/");
|
|
43
|
+
const matches = (pattern: string) => matchesGlob(path, pattern);
|
|
44
|
+
const include = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
45
|
+
const exclude = patterns
|
|
46
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
47
|
+
.map((pattern) => pattern.slice(1));
|
|
48
|
+
return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
|
|
49
|
+
};
|
|
50
|
+
|
|
34
51
|
const isInside = (file: string, root: string): boolean =>
|
|
35
52
|
file === root || file.startsWith(`${root}/`);
|
|
36
53
|
|
|
@@ -49,18 +66,21 @@ const refused = (problem: { readonly message: string }): WalkUnavailable =>
|
|
|
49
66
|
/** `readdir(recursive)` yields paths relative to the root it read: root them. */
|
|
50
67
|
const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
|
|
51
68
|
|
|
69
|
+
/** A test on a path relative to the root being read: whether the run wants it. */
|
|
70
|
+
type Keep = (relative: string) => boolean;
|
|
71
|
+
|
|
52
72
|
/** What the root actually contains: only paths the audit reads. */
|
|
53
|
-
const scannable = (root: string, paths: ReadonlyArray<string>, self: string) =>
|
|
73
|
+
const scannable = (root: string, paths: ReadonlyArray<string>, self: string, keep: Keep) =>
|
|
54
74
|
rooted(
|
|
55
75
|
root,
|
|
56
|
-
paths.filter((relative) => !isInSkippedTree(relative)),
|
|
76
|
+
paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
|
|
57
77
|
).filter((file) => isScannable(file, self));
|
|
58
78
|
|
|
59
79
|
/** One root's paths, filtered to what the audit reads. */
|
|
60
80
|
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
61
|
-
(fs: FileSystem.FileSystem, root: string, self: string) =>
|
|
81
|
+
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
62
82
|
Effect.map(fs.readDirectory(root, { recursive: true }), (paths) =>
|
|
63
|
-
scannable(root, paths, self),
|
|
83
|
+
scannable(root, paths, self, keep),
|
|
64
84
|
),
|
|
65
85
|
);
|
|
66
86
|
|
|
@@ -78,8 +98,8 @@ const readEach = (fs: FileSystem.FileSystem, paths: ReadonlyArray<string>) =>
|
|
|
78
98
|
|
|
79
99
|
/** One root's files, in directory order: read every scannable path. */
|
|
80
100
|
const readRoot = Effect.fn("SourceWalker.readRoot")(
|
|
81
|
-
(fs: FileSystem.FileSystem, root: string, self: string) =>
|
|
82
|
-
Effect.flatMap(listRoot(fs, root, self), (paths) => readEach(fs, paths)),
|
|
101
|
+
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
102
|
+
Effect.flatMap(listRoot(fs, root, self, keep), (paths) => readEach(fs, paths)),
|
|
83
103
|
);
|
|
84
104
|
|
|
85
105
|
/**
|
|
@@ -103,20 +123,24 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
103
123
|
* The scan root is the current working directory, so the audit reads the
|
|
104
124
|
* repository it is run in.
|
|
105
125
|
*/
|
|
106
|
-
export const SourceWalkerLive =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
126
|
+
export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
127
|
+
Layer.effect(SourceWalker)(
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const fs = yield* FileSystem.FileSystem;
|
|
130
|
+
const path = yield* Path.Path;
|
|
131
|
+
// No segments: Path resolves against the working directory.
|
|
132
|
+
const root = path.resolve();
|
|
133
|
+
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
134
|
+
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
135
|
+
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
136
|
+
const trees = yield* Effect.forEach(dirs, (dir) =>
|
|
137
|
+
readRoot(fs, path.join(root, dir), self, (relative) =>
|
|
138
|
+
// Filter patterns are relative to the working directory, not the tree read.
|
|
139
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
|
|
140
|
+
).pipe(Effect.mapError(refused)),
|
|
141
|
+
);
|
|
142
|
+
return trees.flat();
|
|
143
|
+
});
|
|
144
|
+
return SourceWalker.of({ files: walk() });
|
|
145
|
+
}),
|
|
146
|
+
);
|
package/src/workflows/audit.ts
CHANGED
|
@@ -30,11 +30,13 @@ export interface AuditResult {
|
|
|
30
30
|
readonly judged: number;
|
|
31
31
|
readonly cached: number;
|
|
32
32
|
readonly skipped: number;
|
|
33
|
+
/** Files whose checks `--limit` left for a later run, none judged in this one. */
|
|
34
|
+
readonly waiting: number;
|
|
33
35
|
readonly findings: ReadonlyArray<Finding>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface FileResult {
|
|
37
|
-
readonly status: "judged" | "cached" | "skipped";
|
|
39
|
+
readonly status: "judged" | "cached" | "skipped" | "waiting";
|
|
38
40
|
readonly findings: ReadonlyArray<Finding>;
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -69,6 +71,8 @@ export interface FilePlan {
|
|
|
69
71
|
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
70
72
|
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
71
73
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
74
|
+
/** Pending checks `--limit` leaves for a later run. */
|
|
75
|
+
readonly deferred: number;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
@@ -82,6 +86,8 @@ export interface AuditPlan {
|
|
|
82
86
|
readonly cached: number;
|
|
83
87
|
/** Files too long to judge. */
|
|
84
88
|
readonly skipped: number;
|
|
89
|
+
/** Checks `--limit` leaves for a later run. */
|
|
90
|
+
readonly deferred: number;
|
|
85
91
|
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
86
92
|
readonly requests: number;
|
|
87
93
|
}
|
|
@@ -108,69 +114,110 @@ const inFile =
|
|
|
108
114
|
: problem,
|
|
109
115
|
);
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The plan with at most `limit` checks left to judge, taken in path order.
|
|
119
|
+
* Past the limit a file's other checks wait for a later run; judgments are
|
|
120
|
+
* cached, so a rerun picks up where this one stopped.
|
|
121
|
+
*/
|
|
122
|
+
const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
|
|
123
|
+
let left = limit;
|
|
124
|
+
return planned.map((plan) => {
|
|
125
|
+
const ids = Object.keys(plan.pending);
|
|
126
|
+
const taken = new Set(ids.slice(0, left));
|
|
127
|
+
left -= taken.size;
|
|
128
|
+
return taken.size === ids.length
|
|
129
|
+
? plan
|
|
130
|
+
: {
|
|
131
|
+
...plan,
|
|
132
|
+
pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
|
|
133
|
+
deferred: ids.length - taken.size,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export interface PlanOptions {
|
|
139
|
+
/** Judge at most this many checks; the rest wait for a later run. */
|
|
140
|
+
readonly limit?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const planAudit = (
|
|
144
|
+
options: PlanOptions = {},
|
|
145
|
+
): Effect.Effect<
|
|
112
146
|
AuditPlan,
|
|
113
147
|
WalkUnavailable,
|
|
114
148
|
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
115
|
-
>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
149
|
+
> =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const config = yield* AdhereConfig;
|
|
152
|
+
const cache = yield* AuditCache;
|
|
153
|
+
const files = yield* (yield* SourceWalker).files;
|
|
119
154
|
|
|
120
|
-
|
|
121
|
-
|
|
155
|
+
const configuredRules = (file: string) =>
|
|
156
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
122
157
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
158
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
159
|
+
const rules = configuredRules(file.path);
|
|
160
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
161
|
+
if (!fits(file.lines, rules)) {
|
|
162
|
+
return {
|
|
163
|
+
file,
|
|
164
|
+
skipped: true,
|
|
165
|
+
hash: "",
|
|
166
|
+
prepared: {},
|
|
167
|
+
kept: {},
|
|
168
|
+
pending: {},
|
|
169
|
+
deferred: 0,
|
|
170
|
+
} satisfies FilePlan;
|
|
171
|
+
}
|
|
172
|
+
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
173
|
+
Object.entries(rules),
|
|
174
|
+
([id, rule]) =>
|
|
175
|
+
Effect.map(
|
|
176
|
+
fingerprintOf(config.model, rule),
|
|
177
|
+
(fingerprint) =>
|
|
178
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
179
|
+
),
|
|
180
|
+
).pipe(Effect.map(Record.fromEntries));
|
|
181
|
+
const hash = yield* sha256(file.lines.join("\n"));
|
|
182
|
+
const entry = yield* cache.get(file.path);
|
|
183
|
+
const remembered = entry?.hash === hash ? entry.judgments : {};
|
|
184
|
+
const kept = Record.filter(
|
|
185
|
+
remembered,
|
|
186
|
+
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
187
|
+
);
|
|
188
|
+
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
127
189
|
return {
|
|
128
190
|
file,
|
|
129
|
-
skipped:
|
|
130
|
-
hash
|
|
131
|
-
prepared
|
|
132
|
-
kept
|
|
133
|
-
pending
|
|
191
|
+
skipped: false,
|
|
192
|
+
hash,
|
|
193
|
+
prepared,
|
|
194
|
+
kept,
|
|
195
|
+
pending,
|
|
196
|
+
deferred: 0,
|
|
134
197
|
} satisfies FilePlan;
|
|
135
|
-
}
|
|
136
|
-
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
137
|
-
Object.entries(rules),
|
|
138
|
-
([id, rule]) =>
|
|
139
|
-
Effect.map(
|
|
140
|
-
fingerprintOf(config.model, rule),
|
|
141
|
-
(fingerprint) =>
|
|
142
|
-
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
143
|
-
),
|
|
144
|
-
).pipe(Effect.map(Record.fromEntries));
|
|
145
|
-
const hash = yield* sha256(file.lines.join("\n"));
|
|
146
|
-
const entry = yield* cache.get(file.path);
|
|
147
|
-
const remembered = entry?.hash === hash ? entry.judgments : {};
|
|
148
|
-
const kept = Record.filter(
|
|
149
|
-
remembered,
|
|
150
|
-
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
151
|
-
);
|
|
152
|
-
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
153
|
-
return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
|
|
154
|
-
});
|
|
198
|
+
});
|
|
155
199
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
200
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
201
|
+
const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
202
|
+
concurrency: 8,
|
|
203
|
+
});
|
|
204
|
+
const planned =
|
|
205
|
+
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
206
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
207
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
208
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
209
|
+
return {
|
|
210
|
+
files: planned,
|
|
211
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
212
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
213
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
214
|
+
skipped: planned.length - judged.length,
|
|
215
|
+
deferred: total((plan) => plan.deferred),
|
|
216
|
+
requests: total((plan) =>
|
|
217
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
218
|
+
),
|
|
219
|
+
};
|
|
159
220
|
});
|
|
160
|
-
const judged = planned.filter((plan) => !plan.skipped);
|
|
161
|
-
const total = (count: (plan: FilePlan) => number) =>
|
|
162
|
-
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
163
|
-
return {
|
|
164
|
-
files: planned,
|
|
165
|
-
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
166
|
-
checks: total((plan) => sizeOf(plan.prepared)),
|
|
167
|
-
cached: total((plan) => sizeOf(plan.kept)),
|
|
168
|
-
skipped: planned.length - judged.length,
|
|
169
|
-
requests: total((plan) =>
|
|
170
|
-
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
171
|
-
),
|
|
172
|
-
};
|
|
173
|
-
});
|
|
174
221
|
|
|
175
222
|
/**
|
|
176
223
|
* Judges what the plan left pending and locates the findings, a file at a
|
|
@@ -191,6 +238,7 @@ export const executeAudit = (
|
|
|
191
238
|
prepared,
|
|
192
239
|
kept,
|
|
193
240
|
pending,
|
|
241
|
+
deferred,
|
|
194
242
|
}: FilePlan) {
|
|
195
243
|
const probabilities = isEmpty(pending)
|
|
196
244
|
? {}
|
|
@@ -243,7 +291,7 @@ export const executeAudit = (
|
|
|
243
291
|
})
|
|
244
292
|
.sort((a, b) => a.line - b.line);
|
|
245
293
|
return {
|
|
246
|
-
result: fileResult(cached ? "cached" : "judged", findings),
|
|
294
|
+
result: fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings),
|
|
247
295
|
requests:
|
|
248
296
|
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
249
297
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
@@ -274,6 +322,7 @@ export const executeAudit = (
|
|
|
274
322
|
judged: count("judged"),
|
|
275
323
|
cached: count("cached"),
|
|
276
324
|
skipped: count("skipped"),
|
|
325
|
+
waiting: count("waiting"),
|
|
277
326
|
findings: results.flatMap((result) => result.findings),
|
|
278
327
|
};
|
|
279
328
|
});
|
|
@@ -283,6 +332,6 @@ export const runAudit: Effect.Effect<
|
|
|
283
332
|
AuditResult,
|
|
284
333
|
WalkUnavailable | JevUnavailable,
|
|
285
334
|
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
286
|
-
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
335
|
+
> = Effect.flatMap(planAudit(), (plan) => executeAudit(plan));
|
|
287
336
|
|
|
288
337
|
export { type RenderOptions, render } from "#workflows/format.ts";
|
package/src/workflows/format.ts
CHANGED
|
@@ -124,6 +124,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
124
124
|
`${result.judged} judged`,
|
|
125
125
|
`${result.cached} cached`,
|
|
126
126
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
127
|
+
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
127
128
|
];
|
|
128
129
|
return [
|
|
129
130
|
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
@@ -153,18 +154,56 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
153
154
|
* between them and how many the cache answers, then what judging the rest
|
|
154
155
|
* takes.
|
|
155
156
|
*/
|
|
156
|
-
|
|
157
|
+
/** What a run was asked to keep to, for the plan to say. */
|
|
158
|
+
export interface RunLimits {
|
|
159
|
+
readonly filter?: ReadonlyArray<string>;
|
|
160
|
+
readonly rpm?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
|
|
164
|
+
const durationAt = (requests: number, rpm: number): string => {
|
|
165
|
+
const seconds = Math.ceil((requests / rpm) * 60);
|
|
166
|
+
return seconds < 60
|
|
167
|
+
? counted(seconds, "second", "seconds")
|
|
168
|
+
: counted(Math.round(seconds / 60), "minute", "minutes");
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
|
|
157
172
|
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
158
173
|
const skipped =
|
|
159
174
|
plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
|
|
160
|
-
const
|
|
175
|
+
const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
|
|
176
|
+
const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
|
|
177
|
+
const pending = plan.checks - plan.cached;
|
|
178
|
+
const waiting =
|
|
179
|
+
plan.deferred > 0
|
|
180
|
+
? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
|
|
181
|
+
: "";
|
|
161
182
|
if (plan.requests === 0) {
|
|
162
|
-
|
|
183
|
+
const none =
|
|
184
|
+
plan.deferred > 0
|
|
185
|
+
? plan.deferred === 1
|
|
186
|
+
? "The limit leaves the 1 unjudged check for a later run."
|
|
187
|
+
: `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
|
|
188
|
+
: plan.checks === 0
|
|
189
|
+
? "Nothing to judge."
|
|
190
|
+
: "The cache answers every check.";
|
|
191
|
+
return [found, none];
|
|
163
192
|
}
|
|
164
|
-
const
|
|
193
|
+
const judging = pending - plan.deferred;
|
|
194
|
+
const rest =
|
|
195
|
+
plan.deferred > 0
|
|
196
|
+
? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
|
|
197
|
+
: plan.cached > 0
|
|
198
|
+
? `the other ${pending}`
|
|
199
|
+
: "them";
|
|
200
|
+
const pace =
|
|
201
|
+
limits.rpm === undefined
|
|
202
|
+
? ""
|
|
203
|
+
: ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
|
|
165
204
|
return [
|
|
166
205
|
found,
|
|
167
|
-
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding
|
|
206
|
+
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
|
|
168
207
|
];
|
|
169
208
|
};
|
|
170
209
|
|