@drkmttr/adhere 0.7.0 → 0.7.3
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 +29 -0
- package/package.json +7 -6
- package/src/cli.ts +69 -34
- package/src/config.ts +5 -0
- package/src/services/Jev.http.ts +53 -11
- package/src/services/Jev.ts +12 -2
- package/src/services/SourceWalker.ts +47 -23
- package/src/workflows/audit.ts +145 -59
- package/src/workflows/format.ts +95 -17
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darkmatter Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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,28 @@ 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
|
+
The firewall in front of Jev's API can refuse a request whose code reads to
|
|
111
|
+
it as an attack, with a 403 and an HTML page rather than Jev's JSON. It refuses
|
|
112
|
+
the same request every run, so that file is skipped, the run goes on, and the
|
|
113
|
+
report lists each such file with Cloudflare's Ray ID, which TypeSafe AI can
|
|
114
|
+
look the block up by. When only the question that finds the line is refused,
|
|
115
|
+
the file's judgments are kept, and a rerun sends that question alone.
|
|
116
|
+
|
|
117
|
+
### Limiting a run
|
|
118
|
+
|
|
119
|
+
Three flags bound what a run does. `--limit <checks>` judges at most that many
|
|
120
|
+
checks, taken in path order; the rest wait, and since judgments are cached, the
|
|
121
|
+
next run with the same limit picks up where this one stopped. `--limit 0` shows
|
|
122
|
+
the plan and judges nothing. `--rpm <requests>` sends at most that many requests
|
|
123
|
+
to Jev a minute, evenly spaced, retries included, and the plan says about how
|
|
124
|
+
long they take. `--filter <glob>` reads only the files whose path from the
|
|
125
|
+
working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
|
|
126
|
+
more, and start a pattern with `!` to leave out what it matches. Together:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
|
|
130
|
+
```
|
|
131
|
+
|
|
107
132
|
### What gets read
|
|
108
133
|
|
|
109
134
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
|
@@ -413,3 +438,7 @@ runs. The publish workflow can be run manually for an already-created release
|
|
|
413
438
|
tag if a publish needs to be retried; it skips every package whose version is
|
|
414
439
|
already on npm. The main package's `files` list keeps it to `bin/`, `src/`,
|
|
415
440
|
`presets/`, and `skills/`.
|
|
441
|
+
|
|
442
|
+
## License
|
|
443
|
+
|
|
444
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
|
+
"license": "MIT",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/darkmatter/adhere.git"
|
|
@@ -67,10 +68,10 @@
|
|
|
67
68
|
}
|
|
68
69
|
},
|
|
69
70
|
"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.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.7.3",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.7.3",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.7.3",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.7.3",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.7.3"
|
|
75
76
|
}
|
|
76
77
|
}
|
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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
conflictBody,
|
|
8
8
|
contradictBody,
|
|
9
9
|
Jev,
|
|
10
|
+
JevBlocked,
|
|
10
11
|
JevOverflow,
|
|
11
12
|
JevUnavailable,
|
|
12
13
|
judgeBody,
|
|
@@ -19,9 +20,10 @@ import {
|
|
|
19
20
|
requestsOf,
|
|
20
21
|
type Rules,
|
|
21
22
|
} from "#services/Jev.ts";
|
|
22
|
-
import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
|
+
import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
24
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
24
25
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
26
|
+
import { RateLimiter } from "effect/unstable/persistence";
|
|
25
27
|
|
|
26
28
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
27
29
|
|
|
@@ -47,40 +49,80 @@ const Overflowed = Schema.fromJsonString(
|
|
|
47
49
|
const isOverflow = (body: string): boolean =>
|
|
48
50
|
Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
|
|
49
51
|
|
|
52
|
+
/** A firewall's block page: HTML where Jev answers JSON, even for its own errors. */
|
|
53
|
+
const isBlockPage = (response: HttpClientResponse.HttpClientResponse, body: string): boolean =>
|
|
54
|
+
(response.headers["content-type"] ?? "").includes("text/html") ||
|
|
55
|
+
body.trimStart().startsWith("<");
|
|
56
|
+
|
|
50
57
|
/**
|
|
51
58
|
* A request that failed. A 400 saying the request is over Jev's context is a
|
|
52
59
|
* `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
|
|
53
60
|
* the run with its status and what Jev said, since the status alone does not
|
|
54
61
|
* say why, and a request that got no answer refuses with the cause.
|
|
55
62
|
*/
|
|
56
|
-
const failed = (
|
|
63
|
+
const failed = (
|
|
64
|
+
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
65
|
+
) => {
|
|
57
66
|
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
58
67
|
return Effect.fail(refused(`System One request failed: ${problem.message}`));
|
|
59
68
|
}
|
|
60
69
|
const { response } = problem.reason;
|
|
61
70
|
return Effect.flatMap(
|
|
62
71
|
Effect.orElseSucceed(response.text, () => ""),
|
|
63
|
-
(body): Effect.Effect<never, JevOverflow | JevUnavailable> =>
|
|
72
|
+
(body): Effect.Effect<never, JevOverflow | JevBlocked | JevUnavailable> =>
|
|
64
73
|
response.status === 400 && isOverflow(body)
|
|
65
74
|
? Effect.fail(JevOverflow.make())
|
|
66
|
-
:
|
|
75
|
+
: response.status === 403 && isBlockPage(response, body)
|
|
76
|
+
? Effect.fail(JevBlocked.make({ ray: response.headers["cf-ray"] ?? "none given" }))
|
|
77
|
+
: Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
|
|
67
78
|
);
|
|
68
79
|
};
|
|
69
80
|
|
|
70
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Comparing rules has no file to skip, so a request over Jev's context, or one
|
|
83
|
+
* the firewall blocks, refuses instead.
|
|
84
|
+
*/
|
|
71
85
|
const refuseOverflow = <A>(
|
|
72
|
-
self: Effect.Effect<A, JevOverflow | JevUnavailable>,
|
|
86
|
+
self: Effect.Effect<A, JevOverflow | JevBlocked | JevUnavailable>,
|
|
73
87
|
): Effect.Effect<A, JevUnavailable> =>
|
|
74
|
-
|
|
75
|
-
Effect.
|
|
88
|
+
self.pipe(
|
|
89
|
+
Effect.catchTag("JevOverflow", () =>
|
|
90
|
+
Effect.fail(refused("Comparing the rules took a request over Jev's context")),
|
|
91
|
+
),
|
|
92
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
93
|
+
Effect.fail(
|
|
94
|
+
refused(
|
|
95
|
+
`The firewall in front of Jev's API blocked comparing the rules (Cloudflare Ray ID ${ray})`,
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
76
99
|
);
|
|
77
100
|
|
|
78
101
|
export const JevLive = Layer.effect(Jev)(
|
|
79
102
|
Effect.gen(function* () {
|
|
80
103
|
const config = yield* AdhereConfig;
|
|
81
104
|
const credentials = yield* Credentials;
|
|
82
|
-
const
|
|
83
|
-
|
|
105
|
+
const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
106
|
+
// `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
|
|
107
|
+
// burst each minute. Before the retries, so a retry waits its turn too.
|
|
108
|
+
type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
|
|
109
|
+
const paced: HttpClient.HttpClient.With<Failure> =
|
|
110
|
+
config.rpm === undefined
|
|
111
|
+
? // The same client, typed to fail the ways the throttled one can.
|
|
112
|
+
HttpClient.transformResponse(
|
|
113
|
+
http,
|
|
114
|
+
(response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
|
|
115
|
+
)
|
|
116
|
+
: http.pipe(
|
|
117
|
+
HttpClient.withRateLimiter({
|
|
118
|
+
limiter: yield* RateLimiter.RateLimiter,
|
|
119
|
+
key: "jev",
|
|
120
|
+
algorithm: "token-bucket",
|
|
121
|
+
limit: 1,
|
|
122
|
+
window: Duration.millis(60_000 / config.rpm),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
const client = paced.pipe(
|
|
84
126
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
85
127
|
HttpClient.retryTransient({
|
|
86
128
|
schedule: Schedule.exponential("500 millis"),
|
|
@@ -172,4 +214,4 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
172
214
|
|
|
173
215
|
return Jev.of({ judge, locate, conflicts, contradicts });
|
|
174
216
|
}),
|
|
175
|
-
);
|
|
217
|
+
).pipe(Layer.provide(RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))));
|
package/src/services/Jev.ts
CHANGED
|
@@ -14,17 +14,27 @@ 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
|
+
|
|
17
27
|
export class Jev extends Context.Service<
|
|
18
28
|
Jev,
|
|
19
29
|
{
|
|
20
30
|
readonly judge: (
|
|
21
31
|
lines: Lines,
|
|
22
32
|
rules: Rules,
|
|
23
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
33
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow | JevBlocked>;
|
|
24
34
|
readonly locate: (
|
|
25
35
|
lines: Lines,
|
|
26
36
|
rules: Rules,
|
|
27
|
-
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
|
|
37
|
+
) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow | JevBlocked>;
|
|
28
38
|
/**
|
|
29
39
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
30
40
|
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
@@ -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,12 +30,23 @@ 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;
|
|
35
|
+
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
36
|
+
readonly blocked: ReadonlyArray<Blocked>;
|
|
33
37
|
readonly findings: ReadonlyArray<Finding>;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
|
|
41
|
+
export interface Blocked {
|
|
42
|
+
readonly file: string;
|
|
43
|
+
readonly ray: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
interface FileResult {
|
|
37
|
-
readonly status: "judged" | "cached" | "skipped";
|
|
47
|
+
readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
|
|
38
48
|
readonly findings: ReadonlyArray<Finding>;
|
|
49
|
+
readonly blocked?: Blocked;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
interface PreparedRule {
|
|
@@ -69,6 +80,8 @@ export interface FilePlan {
|
|
|
69
80
|
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
70
81
|
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
71
82
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
83
|
+
/** Pending checks `--limit` leaves for a later run. */
|
|
84
|
+
readonly deferred: number;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
@@ -82,6 +95,8 @@ export interface AuditPlan {
|
|
|
82
95
|
readonly cached: number;
|
|
83
96
|
/** Files too long to judge. */
|
|
84
97
|
readonly skipped: number;
|
|
98
|
+
/** Checks `--limit` leaves for a later run. */
|
|
99
|
+
readonly deferred: number;
|
|
85
100
|
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
86
101
|
readonly requests: number;
|
|
87
102
|
}
|
|
@@ -108,69 +123,110 @@ const inFile =
|
|
|
108
123
|
: problem,
|
|
109
124
|
);
|
|
110
125
|
|
|
111
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The plan with at most `limit` checks left to judge, taken in path order.
|
|
128
|
+
* Past the limit a file's other checks wait for a later run; judgments are
|
|
129
|
+
* cached, so a rerun picks up where this one stopped.
|
|
130
|
+
*/
|
|
131
|
+
const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
|
|
132
|
+
let left = limit;
|
|
133
|
+
return planned.map((plan) => {
|
|
134
|
+
const ids = Object.keys(plan.pending);
|
|
135
|
+
const taken = new Set(ids.slice(0, left));
|
|
136
|
+
left -= taken.size;
|
|
137
|
+
return taken.size === ids.length
|
|
138
|
+
? plan
|
|
139
|
+
: {
|
|
140
|
+
...plan,
|
|
141
|
+
pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
|
|
142
|
+
deferred: ids.length - taken.size,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export interface PlanOptions {
|
|
148
|
+
/** Judge at most this many checks; the rest wait for a later run. */
|
|
149
|
+
readonly limit?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const planAudit = (
|
|
153
|
+
options: PlanOptions = {},
|
|
154
|
+
): Effect.Effect<
|
|
112
155
|
AuditPlan,
|
|
113
156
|
WalkUnavailable,
|
|
114
157
|
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
115
|
-
>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
158
|
+
> =>
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
const config = yield* AdhereConfig;
|
|
161
|
+
const cache = yield* AuditCache;
|
|
162
|
+
const files = yield* (yield* SourceWalker).files;
|
|
119
163
|
|
|
120
|
-
|
|
121
|
-
|
|
164
|
+
const configuredRules = (file: string) =>
|
|
165
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
122
166
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
168
|
+
const rules = configuredRules(file.path);
|
|
169
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
170
|
+
if (!fits(file.lines, rules)) {
|
|
171
|
+
return {
|
|
172
|
+
file,
|
|
173
|
+
skipped: true,
|
|
174
|
+
hash: "",
|
|
175
|
+
prepared: {},
|
|
176
|
+
kept: {},
|
|
177
|
+
pending: {},
|
|
178
|
+
deferred: 0,
|
|
179
|
+
} satisfies FilePlan;
|
|
180
|
+
}
|
|
181
|
+
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
182
|
+
Object.entries(rules),
|
|
183
|
+
([id, rule]) =>
|
|
184
|
+
Effect.map(
|
|
185
|
+
fingerprintOf(config.model, rule),
|
|
186
|
+
(fingerprint) =>
|
|
187
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
188
|
+
),
|
|
189
|
+
).pipe(Effect.map(Record.fromEntries));
|
|
190
|
+
const hash = yield* sha256(file.lines.join("\n"));
|
|
191
|
+
const entry = yield* cache.get(file.path);
|
|
192
|
+
const remembered = entry?.hash === hash ? entry.judgments : {};
|
|
193
|
+
const kept = Record.filter(
|
|
194
|
+
remembered,
|
|
195
|
+
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
196
|
+
);
|
|
197
|
+
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
127
198
|
return {
|
|
128
199
|
file,
|
|
129
|
-
skipped:
|
|
130
|
-
hash
|
|
131
|
-
prepared
|
|
132
|
-
kept
|
|
133
|
-
pending
|
|
200
|
+
skipped: false,
|
|
201
|
+
hash,
|
|
202
|
+
prepared,
|
|
203
|
+
kept,
|
|
204
|
+
pending,
|
|
205
|
+
deferred: 0,
|
|
134
206
|
} 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
|
-
});
|
|
207
|
+
});
|
|
155
208
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
209
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
210
|
+
const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
211
|
+
concurrency: 8,
|
|
212
|
+
});
|
|
213
|
+
const planned =
|
|
214
|
+
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
215
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
216
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
217
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
218
|
+
return {
|
|
219
|
+
files: planned,
|
|
220
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
221
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
222
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
223
|
+
skipped: planned.length - judged.length,
|
|
224
|
+
deferred: total((plan) => plan.deferred),
|
|
225
|
+
requests: total((plan) =>
|
|
226
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
227
|
+
),
|
|
228
|
+
};
|
|
159
229
|
});
|
|
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
230
|
|
|
175
231
|
/**
|
|
176
232
|
* Judges what the plan left pending and locates the findings, a file at a
|
|
@@ -191,6 +247,7 @@ export const executeAudit = (
|
|
|
191
247
|
prepared,
|
|
192
248
|
kept,
|
|
193
249
|
pending,
|
|
250
|
+
deferred,
|
|
194
251
|
}: FilePlan) {
|
|
195
252
|
const probabilities = isEmpty(pending)
|
|
196
253
|
? {}
|
|
@@ -209,9 +266,22 @@ export const executeAudit = (
|
|
|
209
266
|
judgment.line === undefined
|
|
210
267
|
);
|
|
211
268
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
269
|
+
// A blocked line question still leaves the judgments worth caching: a rerun
|
|
270
|
+
// asks only for the lines again, not for every rule.
|
|
271
|
+
const {
|
|
272
|
+
lines,
|
|
273
|
+
blocked,
|
|
274
|
+
}: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
|
|
275
|
+
flagged,
|
|
276
|
+
)
|
|
277
|
+
? { lines: {}, blocked: undefined }
|
|
278
|
+
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
279
|
+
Effect.map((lines) => ({ lines, blocked: undefined })),
|
|
280
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
281
|
+
Effect.succeed({ lines: {}, blocked: { file: file.path, ray } }),
|
|
282
|
+
),
|
|
283
|
+
inFile(file),
|
|
284
|
+
);
|
|
215
285
|
const located = Record.map(judged, (judgment, id) => {
|
|
216
286
|
const line = lines[id];
|
|
217
287
|
return line === undefined
|
|
@@ -243,7 +313,10 @@ export const executeAudit = (
|
|
|
243
313
|
})
|
|
244
314
|
.sort((a, b) => a.line - b.line);
|
|
245
315
|
return {
|
|
246
|
-
result:
|
|
316
|
+
result:
|
|
317
|
+
blocked === undefined
|
|
318
|
+
? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
|
|
319
|
+
: { status: "blocked", findings, blocked },
|
|
247
320
|
requests:
|
|
248
321
|
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
249
322
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
@@ -261,6 +334,17 @@ export const executeAudit = (
|
|
|
261
334
|
requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
262
335
|
}),
|
|
263
336
|
),
|
|
337
|
+
// The firewall refuses this file's code every time; the rest of the run goes on.
|
|
338
|
+
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
339
|
+
Effect.succeed({
|
|
340
|
+
result: {
|
|
341
|
+
status: "blocked",
|
|
342
|
+
findings: [],
|
|
343
|
+
blocked: { file: plan.file.path, ray },
|
|
344
|
+
} satisfies FileResult,
|
|
345
|
+
requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
346
|
+
}),
|
|
347
|
+
),
|
|
264
348
|
);
|
|
265
349
|
yield* progress({ requests, findings: result.findings.length });
|
|
266
350
|
return result;
|
|
@@ -274,6 +358,8 @@ export const executeAudit = (
|
|
|
274
358
|
judged: count("judged"),
|
|
275
359
|
cached: count("cached"),
|
|
276
360
|
skipped: count("skipped"),
|
|
361
|
+
waiting: count("waiting"),
|
|
362
|
+
blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
|
|
277
363
|
findings: results.flatMap((result) => result.findings),
|
|
278
364
|
};
|
|
279
365
|
});
|
|
@@ -283,6 +369,6 @@ export const runAudit: Effect.Effect<
|
|
|
283
369
|
AuditResult,
|
|
284
370
|
WalkUnavailable | JevUnavailable,
|
|
285
371
|
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
286
|
-
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
372
|
+
> = Effect.flatMap(planAudit(), (plan) => executeAudit(plan));
|
|
287
373
|
|
|
288
374
|
export { type RenderOptions, render } from "#workflows/format.ts";
|
package/src/workflows/format.ts
CHANGED
|
@@ -6,7 +6,15 @@ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit
|
|
|
6
6
|
* token of code, named by its role. Layout measures text; only `serialize`
|
|
7
7
|
* turns a role into color, so no width or blank line ever sees an escape code.
|
|
8
8
|
*/
|
|
9
|
-
type Role =
|
|
9
|
+
type Role =
|
|
10
|
+
| "error"
|
|
11
|
+
| "probability"
|
|
12
|
+
| "description"
|
|
13
|
+
| "path"
|
|
14
|
+
| "lineNumber"
|
|
15
|
+
| "underline"
|
|
16
|
+
| "label"
|
|
17
|
+
| Kind;
|
|
10
18
|
|
|
11
19
|
interface Span {
|
|
12
20
|
readonly text: string;
|
|
@@ -16,17 +24,22 @@ interface Span {
|
|
|
16
24
|
type Line = ReadonlyArray<Span>;
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
* SGR parameters per role. The frame's
|
|
20
|
-
* (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* SGR parameters per role. The frame's are set colors, from the 256-color
|
|
28
|
+
* palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
29
|
+
* and a dim line number. Of the header, only the `×` and the rule are red,
|
|
30
|
+
* since a whole line of red is hard to read: the probability is an accent, and
|
|
31
|
+
* the description a soft white. The code's are the terminal's own colors
|
|
32
|
+
* (`3N`), so the user's theme picks shades that read on its background; none
|
|
33
|
+
* is magenta, which would run into the label.
|
|
23
34
|
*/
|
|
24
35
|
const THEME: Readonly<Record<Role, string>> = {
|
|
25
|
-
error: "38;
|
|
36
|
+
error: "38;5;197;1",
|
|
37
|
+
probability: "38;2;250;179;135",
|
|
38
|
+
description: "38;2;242;205;205",
|
|
26
39
|
path: "38;2;5;125;160;1",
|
|
27
40
|
lineNumber: "2",
|
|
28
41
|
underline: "38;2;255;0;175",
|
|
29
|
-
label: "38;
|
|
42
|
+
label: "38;5;212",
|
|
30
43
|
comment: "2",
|
|
31
44
|
string: "32",
|
|
32
45
|
constant: "33",
|
|
@@ -77,9 +90,11 @@ const header = (finding: Finding): Line => [
|
|
|
77
90
|
span(" "),
|
|
78
91
|
span("×", "error"),
|
|
79
92
|
span(" "),
|
|
80
|
-
span(
|
|
81
|
-
span("
|
|
82
|
-
span(finding.
|
|
93
|
+
span(finding.rule, "error"),
|
|
94
|
+
span(" ("),
|
|
95
|
+
span(finding.probability.toFixed(2), "probability"),
|
|
96
|
+
span("): "),
|
|
97
|
+
span(finding.description, "description"),
|
|
83
98
|
];
|
|
84
99
|
|
|
85
100
|
/** The path, the offending line numbered in the gutter, and an underline as long as the line. */
|
|
@@ -109,8 +124,9 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
|
|
|
109
124
|
};
|
|
110
125
|
|
|
111
126
|
/**
|
|
112
|
-
* One diagnostic, in the frame `vp lint` prints on a terminal: a
|
|
113
|
-
* the offending line, a pink underline, and the code to write
|
|
127
|
+
* One diagnostic, in the frame `vp lint` prints on a terminal: a header with
|
|
128
|
+
* the rule in red, the offending line, a pink underline, and the code to write
|
|
129
|
+
* as the hint.
|
|
114
130
|
*/
|
|
115
131
|
const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
|
|
116
132
|
header(finding),
|
|
@@ -124,6 +140,8 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
124
140
|
`${result.judged} judged`,
|
|
125
141
|
`${result.cached} cached`,
|
|
126
142
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
143
|
+
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
144
|
+
...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
|
|
127
145
|
];
|
|
128
146
|
return [
|
|
129
147
|
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
@@ -131,6 +149,27 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
131
149
|
];
|
|
132
150
|
};
|
|
133
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The files the firewall in front of Jev's API refused, each with the Ray ID
|
|
154
|
+
* TypeSafe AI can look the block up by, so a gap in the audit says where it is.
|
|
155
|
+
*/
|
|
156
|
+
const blockedFiles = (result: AuditResult, root: string | undefined): ReadonlyArray<Line> =>
|
|
157
|
+
result.blocked.length === 0
|
|
158
|
+
? []
|
|
159
|
+
: [
|
|
160
|
+
[],
|
|
161
|
+
[
|
|
162
|
+
span(
|
|
163
|
+
`The firewall in front of Jev's API blocked ${counted(result.blocked.length, "file", "files")}, so ${result.blocked.length === 1 ? "it is" : "they are"} not judged in full. TypeSafe AI can look each block up by its Ray ID:`,
|
|
164
|
+
),
|
|
165
|
+
],
|
|
166
|
+
...result.blocked.map(({ file, ray }) => [
|
|
167
|
+
span(" "),
|
|
168
|
+
span(displayPath(file, root), "path"),
|
|
169
|
+
span(` (Ray ID ${ray})`),
|
|
170
|
+
]),
|
|
171
|
+
];
|
|
172
|
+
|
|
134
173
|
export interface RenderOptions {
|
|
135
174
|
/** Color the frame the way `vp lint` does on a terminal, and highlight the code. */
|
|
136
175
|
readonly color?: boolean;
|
|
@@ -146,6 +185,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
146
185
|
[
|
|
147
186
|
...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
|
|
148
187
|
...summary(result),
|
|
188
|
+
...blockedFiles(result, options.root),
|
|
149
189
|
].map((line) => serialize(line, options.color === true));
|
|
150
190
|
|
|
151
191
|
/**
|
|
@@ -153,18 +193,56 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
153
193
|
* between them and how many the cache answers, then what judging the rest
|
|
154
194
|
* takes.
|
|
155
195
|
*/
|
|
156
|
-
|
|
196
|
+
/** What a run was asked to keep to, for the plan to say. */
|
|
197
|
+
export interface RunLimits {
|
|
198
|
+
readonly filter?: ReadonlyArray<string>;
|
|
199
|
+
readonly rpm?: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
|
|
203
|
+
const durationAt = (requests: number, rpm: number): string => {
|
|
204
|
+
const seconds = Math.ceil((requests / rpm) * 60);
|
|
205
|
+
return seconds < 60
|
|
206
|
+
? counted(seconds, "second", "seconds")
|
|
207
|
+
: counted(Math.round(seconds / 60), "minute", "minutes");
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
|
|
157
211
|
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
158
212
|
const skipped =
|
|
159
213
|
plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
|
|
160
|
-
const
|
|
214
|
+
const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
|
|
215
|
+
const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
|
|
216
|
+
const pending = plan.checks - plan.cached;
|
|
217
|
+
const waiting =
|
|
218
|
+
plan.deferred > 0
|
|
219
|
+
? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
|
|
220
|
+
: "";
|
|
161
221
|
if (plan.requests === 0) {
|
|
162
|
-
|
|
222
|
+
const none =
|
|
223
|
+
plan.deferred > 0
|
|
224
|
+
? plan.deferred === 1
|
|
225
|
+
? "The limit leaves the 1 unjudged check for a later run."
|
|
226
|
+
: `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
|
|
227
|
+
: plan.checks === 0
|
|
228
|
+
? "Nothing to judge."
|
|
229
|
+
: "The cache answers every check.";
|
|
230
|
+
return [found, none];
|
|
163
231
|
}
|
|
164
|
-
const
|
|
232
|
+
const judging = pending - plan.deferred;
|
|
233
|
+
const rest =
|
|
234
|
+
plan.deferred > 0
|
|
235
|
+
? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
|
|
236
|
+
: plan.cached > 0
|
|
237
|
+
? `the other ${pending}`
|
|
238
|
+
: "them";
|
|
239
|
+
const pace =
|
|
240
|
+
limits.rpm === undefined
|
|
241
|
+
? ""
|
|
242
|
+
: ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
|
|
165
243
|
return [
|
|
166
244
|
found,
|
|
167
|
-
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding
|
|
245
|
+
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
|
|
168
246
|
];
|
|
169
247
|
};
|
|
170
248
|
|