@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 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.0",
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.0",
71
- "@drkmttr/adhere-darwin-x64": "0.7.0",
72
- "@drkmttr/adhere-linux-arm64": "0.7.0",
73
- "@drkmttr/adhere-linux-x64": "0.7.0",
74
- "@drkmttr/adhere-win32-x64": "0.7.0"
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("lint", { preset, threshold, yes }, (input) =>
126
- Effect.gen(function* () {
127
- const stdio = yield* Stdio.Stdio;
128
- const plan = yield* planAudit;
129
- yield* toStderr(`${describePlan(plan).join("\n")}\n`);
130
- // A prompt draws on stdout, so it needs a terminal at both ends: none when
131
- // the report is redirected, or in CI.
132
- const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
133
- if (plan.requests > 0 && interactive && !input.yes) {
134
- const send = yield* Prompt.run(
135
- Prompt.confirm({ message: sendQuestion(plan), initial: true }),
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
- if (!send) {
138
- yield* toStderr("Nothing sent.\n");
139
- return yield* Cancelled.make({});
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
- presets: chosenPresets(input.preset),
162
- threshold: Option.getOrUndefined(input.threshold),
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
 
@@ -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 = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
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
- : Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
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
- /** Comparing rules has no file to skip, so a request over Jev's context refuses instead. */
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
- Effect.catchTag(self, "JevOverflow", () =>
75
- Effect.fail(refused("Comparing the rules took a request over Jev's context")),
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 client = (yield* HttpClient.HttpClient).pipe(
83
- HttpClient.filterStatusOk,
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))));
@@ -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 = Layer.effect(SourceWalker)(
107
- Effect.gen(function* () {
108
- const fs = yield* FileSystem.FileSystem;
109
- const path = yield* Path.Path;
110
- // No segments: Path resolves against the working directory.
111
- const root = path.resolve();
112
- const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
113
- const walk = Effect.fn("SourceWalker.files")(function* () {
114
- const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
115
- const trees = yield* Effect.forEach(dirs, (dir) =>
116
- readRoot(fs, path.join(root, dir), self).pipe(Effect.mapError(refused)),
117
- );
118
- return trees.flat();
119
- });
120
- return SourceWalker.of({ files: walk() });
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
+ );
@@ -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
- export const planAudit: Effect.Effect<
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
- > = Effect.gen(function* () {
116
- const config = yield* AdhereConfig;
117
- const cache = yield* AuditCache;
118
- const files = yield* (yield* SourceWalker).files;
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
- const configuredRules = (file: string) =>
121
- config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
164
+ const configuredRules = (file: string) =>
165
+ config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
122
166
 
123
- const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
124
- const rules = configuredRules(file.path);
125
- // Too long for Jev's context: skipped and counted, rather than refused mid-run.
126
- if (!fits(file.lines, rules)) {
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: true,
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
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
157
- const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
158
- concurrency: 8,
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
- const lines = isEmpty(flagged)
213
- ? {}
214
- : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(inFile(file));
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: fileResult(cached ? "cached" : "judged", findings),
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";
@@ -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 = "error" | "path" | "lineNumber" | "underline" | "label" | Kind;
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 match `vp lint`: truecolor
20
- * (`38;2;R;G;B`), bold with a trailing `;1`, and a dim line number. The code's
21
- * are the terminal's own colors (`3N`), so the user's theme picks shades that
22
- * read on its background; none is magenta, which would run into the label.
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;2;219;91;81;1",
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;2;180;105;245",
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(`${finding.rule} (${finding.probability.toFixed(2)})`, "error"),
81
- span(": "),
82
- span(finding.description, "error"),
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 red header,
113
- * the offending line, a pink underline, and the code to write as the hint.
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
- export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
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 found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
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
- return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
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 rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
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