@drkmttr/adhere 0.4.0 → 0.4.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 CHANGED
@@ -23,7 +23,8 @@ and run `npx adhere`. To try it without installing, run
23
23
  Windows on x64. It needs Node to start, and no Bun.
24
24
 
25
25
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
26
- with a TypeSafe AI API key in `TYPESAFE_API_KEY`.
26
+ with a TypeSafe AI API key: the one `adhere login` saved, or
27
+ `TYPESAFE_API_KEY` when it is set.
27
28
 
28
29
  ## Quick start
29
30
 
@@ -31,7 +32,7 @@ with a TypeSafe AI API key in `TYPESAFE_API_KEY`.
31
32
  adhere init # .adhere/config.ts and two example rules
32
33
  adhere validate # check the config and rules, without Jev
33
34
  echo ".adhere/cache/" >> .gitignore
34
- export TYPESAFE_API_KEY=...
35
+ adhere login # save your TypeSafe AI API key, once
35
36
  adhere lint # audit the working directory
36
37
  ```
37
38
 
@@ -55,10 +56,11 @@ Found 1 error.
55
56
 
56
57
  The header is the rule id, Jev's probability, and the rule's description. The
57
58
  hint is the rule's reference; a rule with only code to avoid shows that code,
58
- labeled `avoid:`, instead. The exit code is 0 when nothing is reported and 1
59
- when something is. It is also 1 when the run refuses, for example on an invalid
60
- config or rule file, a missing `TYPESAFE_API_KEY`, or an unknown command or
61
- flag; a refusal prints its reason.
59
+ labeled `avoid:`, instead. On a terminal, the report is in color and the code
60
+ in it is highlighted. The exit code is 0 when nothing is reported and 1 when
61
+ something is. It is also 1 when the run refuses, for example on an invalid
62
+ config or rule file, a missing API key, or an unknown command or flag; a
63
+ refusal prints its reason.
62
64
 
63
65
  ## Usage
64
66
 
@@ -68,6 +70,8 @@ adhere lint --preset effect # add a built-in rule set; the config becomes opt
68
70
  adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
69
71
  adhere validate # load the config and rules without Jev, check for contradictions
70
72
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
73
+ adhere login # save a TypeSafe AI API key for later runs
74
+ adhere logout # delete the saved key
71
75
  adhere skill # print the agent skill (below)
72
76
  ```
73
77
 
@@ -103,6 +107,15 @@ opposite textual directives for the same topic, such as "Use service classes
103
107
  for IO" and "Do not use service classes for IO". Same-id nested rules are
104
108
  treated as intentional shadowing rather than contradictions.
105
109
 
110
+ ### Login
111
+
112
+ `adhere login` prompts for a TypeSafe AI API key, masking what you type, and
113
+ saves it to `~/.config/adhere/credentials.json`, or under `$XDG_CONFIG_HOME`
114
+ when that is set, readable only by you. Piped input is read instead of a
115
+ prompt: `adhere login < key.txt`. `lint` uses the saved key, but
116
+ `TYPESAFE_API_KEY`, when set, takes precedence, so CI can pass a key without a
117
+ login. `adhere logout` deletes the saved key.
118
+
106
119
  ## Config
107
120
 
108
121
  A config file is optional. Without one, adhere reads the rule files in
@@ -258,8 +271,8 @@ only that rule. A lowered threshold locates cached judgments that are newly
258
271
  above it without judging again. Entries depend on content, not on the
259
272
  machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
260
273
 
261
- `TYPESAFE_API_KEY` is read only when a request is about to be sent. A run
262
- where every file is cached needs no key and no network.
274
+ The API key is read only when a request is about to be sent. A run where
275
+ every file is cached needs no key and no network.
263
276
 
264
277
  ## Agent skill
265
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against reference code 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.4.0",
71
- "@drkmttr/adhere-darwin-x64": "0.4.0",
72
- "@drkmttr/adhere-linux-arm64": "0.4.0",
73
- "@drkmttr/adhere-linux-x64": "0.4.0",
74
- "@drkmttr/adhere-win32-x64": "0.4.0"
70
+ "@drkmttr/adhere-darwin-arm64": "0.4.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.4.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.4.1",
73
+ "@drkmttr/adhere-linux-x64": "0.4.1",
74
+ "@drkmttr/adhere-win32-x64": "0.4.1"
75
75
  }
76
76
  }
@@ -96,8 +96,9 @@ export default {
96
96
  } satisfies Config;
97
97
  ```
98
98
 
99
- Add `.adhere/cache/` to `.gitignore`. `TYPESAFE_API_KEY` must be set for
100
- uncached files; in this org it comes from SOPS via `just shell`.
99
+ Add `.adhere/cache/` to `.gitignore`. Uncached files need a TypeSafe AI API
100
+ key: one saved by `adhere login`, or `TYPESAFE_API_KEY`, which takes
101
+ precedence; in this org it comes from SOPS via `just shell`.
101
102
 
102
103
  ## 5. Run and calibrate
103
104
 
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AdhereConfig, AdhereConfigLive } from "#services/AdhereConfig.ts";
2
2
  import { AuditCacheLive } from "#services/AuditCache.ts";
3
+ import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
3
4
  import { JevLive } from "#services/Jev.http.ts";
4
5
  import { SourceWalkerLive } from "#services/SourceWalker.ts";
5
6
  import { render, runAudit } from "#workflows/audit.ts";
@@ -14,11 +15,13 @@ import {
14
15
  Layer,
15
16
  Option,
16
17
  Path,
18
+ Redacted,
17
19
  Runtime,
18
20
  Schema,
19
21
  Stdio,
22
+ Stream,
20
23
  } from "effect";
21
- import { Command, Flag } from "effect/unstable/cli";
24
+ import { Command, Flag, Prompt } from "effect/unstable/cli";
22
25
  import { FetchHttpClient } from "effect/unstable/http";
23
26
  // A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
24
27
  // contents become a string at bundle time, so the compiled binary carries the
@@ -63,7 +66,7 @@ export const auditLayer = (overrides: Overrides) =>
63
66
  Layer.mergeAll(
64
67
  SourceWalkerLive,
65
68
  AuditCacheLive,
66
- JevLive.pipe(Layer.provide(FetchHttpClient.layer)),
69
+ JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
67
70
  ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
68
71
 
69
72
  /** `adhere lint`: the audit. */
@@ -127,6 +130,63 @@ export const initCommand = Command.make("init", { force }, (input) =>
127
130
  }),
128
131
  ).pipe(Command.withDescription("Scaffold .adhere/config.ts and example Markdown rules."));
129
132
 
133
+ /**
134
+ * The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
135
+ * Whitespace around it, like the newline a pipe ends with, is dropped.
136
+ */
137
+ const readApiKey = Effect.fn("login.readApiKey")(function* () {
138
+ const stdio = yield* Stdio.Stdio;
139
+ const typed: string = (yield* stdio.stdinIsTerminal)
140
+ ? Redacted.value(yield* Prompt.run(Prompt.password({ message: "TypeSafe AI API key" })))
141
+ : yield* stdio.stdin.pipe(
142
+ Stream.decodeText(),
143
+ Stream.mkString,
144
+ Effect.mapError((problem) =>
145
+ CredentialsUnavailable.make({
146
+ message: `Cannot read the key from stdin: ${problem.message}`,
147
+ }),
148
+ ),
149
+ );
150
+ const key = typed.trim();
151
+ if (key.length === 0) {
152
+ return yield* CredentialsUnavailable.make({
153
+ message: "No API key given: type it at the prompt, or pipe it in.",
154
+ });
155
+ }
156
+ return Redacted.make(key);
157
+ });
158
+
159
+ /** `adhere login`: save the API key, so a run needs no TYPESAFE_API_KEY. */
160
+ export const loginCommand = Command.make("login", {}, () =>
161
+ Effect.gen(function* () {
162
+ const credentials = yield* Credentials;
163
+ yield* credentials.save(yield* readApiKey());
164
+ yield* Console.log(`Saved the API key to ${yield* credentials.file}.`);
165
+ }),
166
+ ).pipe(
167
+ Command.withDescription(
168
+ "Save a TypeSafe AI API key in ~/.config/adhere ($XDG_CONFIG_HOME/adhere when set), readable only by you, so lint runs without TYPESAFE_API_KEY. Prompts for the key, or reads it from stdin when piped.",
169
+ ),
170
+ Command.provide(CredentialsLive),
171
+ );
172
+
173
+ /** `adhere logout`: delete the saved API key. */
174
+ export const logoutCommand = Command.make("logout", {}, () =>
175
+ Effect.gen(function* () {
176
+ const credentials = yield* Credentials;
177
+ const file = yield* credentials.file;
178
+ const removed = yield* credentials.remove;
179
+ yield* Console.log(
180
+ removed ? `Deleted the API key saved in ${file}.` : `No API key is saved in ${file}.`,
181
+ );
182
+ }),
183
+ ).pipe(
184
+ Command.withDescription(
185
+ "Delete the API key adhere login saved. TYPESAFE_API_KEY, when set, still applies.",
186
+ ),
187
+ Command.provide(CredentialsLive),
188
+ );
189
+
130
190
  /** `adhere skill`: the agent skill for writing rules, as shipped in the binary. */
131
191
  export const skillCommand = Command.make("skill", {}, () => Console.log(skill.trimEnd())).pipe(
132
192
  Command.withDescription(
@@ -140,11 +200,19 @@ export const cli = Command.make("adhere").pipe(
140
200
  Command.withExamples([
141
201
  { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
142
202
  { command: "adhere validate", description: "Check the config and rules without calling Jev" },
203
+ { command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
143
204
  { command: "adhere lint", description: "Audit the working directory" },
144
205
  {
145
206
  command: "adhere lint --preset effect",
146
207
  description: "Audit against the built-in Effect rules",
147
208
  },
148
209
  ]),
149
- Command.withSubcommands([lintCommand, validateCommand, initCommand, skillCommand]),
210
+ Command.withSubcommands([
211
+ lintCommand,
212
+ validateCommand,
213
+ initCommand,
214
+ loginCommand,
215
+ logoutCommand,
216
+ skillCommand,
217
+ ]),
150
218
  );
@@ -0,0 +1,106 @@
1
+ /**
2
+ * TypeScript split into the token classes a reader scans for: comments,
3
+ * strings, constants, keywords, and type names. A tokenizer, not a parser: a
4
+ * token it cannot classify has no kind, and the tokens' text joins back into
5
+ * the input exactly.
6
+ */
7
+ export type Kind = "comment" | "string" | "constant" | "keyword" | "type";
8
+
9
+ export interface Token {
10
+ readonly text: string;
11
+ readonly kind?: Kind;
12
+ }
13
+
14
+ const words = (list: string): ReadonlySet<string> => new Set(list.split(" "));
15
+
16
+ const KEYWORDS = words(
17
+ "abstract as async await break case catch class const continue declare default delete do " +
18
+ "else enum export extends finally for from function get if implements import in infer " +
19
+ "instanceof interface is keyof let namespace new of private protected public readonly " +
20
+ "return satisfies set static super switch this throw try type typeof var void while yield",
21
+ );
22
+ const CONSTANTS = words("true false null undefined");
23
+ const PRIMITIVES = words("any bigint boolean never number object string symbol unknown");
24
+ /** Keywords that keep their color before a colon: `default:`, `a ? this : b`. */
25
+ const NOT_KEYS = words("default super this");
26
+
27
+ /** The groups that name their token's kind. */
28
+ const CLASSES = ["comment", "string", "constant"] as const;
29
+
30
+ /** One group per token class, tried in order at each position. */
31
+ const TOKEN = new RegExp(
32
+ [
33
+ /(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
34
+ /(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?|`(?:[^`\\]|\\[\s\S])*`?)/,
35
+ /(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
36
+ /(?<word>[a-z_$][\w$]*)/,
37
+ /(?<space>\s+)/,
38
+ // Anything else, a run at a time; `/` alone, since it can start a comment.
39
+ /(?<punctuation>[^\w\s$"'`/]+|\/)/,
40
+ ]
41
+ .map((group) => group.source)
42
+ .join("|"),
43
+ "iy",
44
+ );
45
+ /** A regex literal, tried only where an expression can start: `a / b` divides. */
46
+ const REGEX = /\/(?![*/])(?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+\/[a-z]*/y;
47
+ /** What follows a property name: `type: "noul"`, `get?: X`. */
48
+ const KEY = /\s*\??:(?!:)/y;
49
+ /** `Schema`, `HttpClient`, `A`; not `URL` or `MAX_LINES`. */
50
+ const TYPE_NAME = /^[A-Z]$|^[A-Z][\w$]*[a-z]/;
51
+
52
+ const matchAt = (pattern: RegExp, code: string, at: number): RegExpExecArray | null => {
53
+ pattern.lastIndex = at;
54
+ return pattern.exec(code);
55
+ };
56
+
57
+ /** After a name, a literal, or a closing bracket, `/` divides. */
58
+ const regexMayStart = (previous: string | undefined): boolean =>
59
+ previous === undefined ||
60
+ (KEYWORDS.has(previous) && previous !== "this" && previous !== "super") ||
61
+ /[^\w$)\]}"'`]$/.test(previous);
62
+
63
+ /**
64
+ * A word after `.` or before `:` names a property, so `cache.get` and
65
+ * `{ type: "noul" }` are not keywords.
66
+ */
67
+ const kindOf = (word: string, previous: string | undefined, isKey: boolean): Kind | undefined => {
68
+ if (TYPE_NAME.test(word)) return "type";
69
+ if (CONSTANTS.has(word)) return "constant";
70
+ if (previous?.endsWith(".") === true || (isKey && !NOT_KEYS.has(word))) return undefined;
71
+ return KEYWORDS.has(word) ? "keyword" : PRIMITIVES.has(word) ? "type" : undefined;
72
+ };
73
+
74
+ /**
75
+ * The code's lines, each as tokens. A token that crosses a newline, like a
76
+ * block comment or a template literal, is cut into one token per line.
77
+ */
78
+ export const tokenize = (code: string): ReadonlyArray<ReadonlyArray<Token>> => {
79
+ const lines: Array<Array<Token>> = [[]];
80
+ // The last token that was not whitespace or a comment.
81
+ let previous: string | undefined;
82
+ let at = 0;
83
+ while (at < code.length) {
84
+ const regex = regexMayStart(previous) ? matchAt(REGEX, code, at) : null;
85
+ const match = regex ?? matchAt(TOKEN, code, at);
86
+ // `punctuation` matches any character the other groups do not.
87
+ if (match === null) break;
88
+ const text = match[0];
89
+ const groups = match.groups ?? {};
90
+ const kind: Kind | undefined =
91
+ regex !== null
92
+ ? "string"
93
+ : groups.word !== undefined
94
+ ? kindOf(groups.word, previous, matchAt(KEY, code, at + text.length) !== null)
95
+ : CLASSES.find((name) => groups[name] !== undefined);
96
+ for (const [index, piece] of text.split("\n").entries()) {
97
+ if (index > 0) lines.push([]);
98
+ if (piece.length > 0) {
99
+ lines.at(-1)?.push(kind === undefined ? { text: piece } : { text: piece, kind });
100
+ }
101
+ }
102
+ if (groups.comment === undefined && groups.space === undefined) previous = text;
103
+ at += text.length;
104
+ }
105
+ return lines;
106
+ };
@@ -0,0 +1,123 @@
1
+ import { Config, Context, Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect";
2
+
3
+ /** No key to send, or the saved key cannot be read or written. */
4
+ export class CredentialsUnavailable extends Schema.TaggedError<CredentialsUnavailable>()(
5
+ "CredentialsUnavailable",
6
+ { message: Schema.String },
7
+ ) {}
8
+
9
+ /** A TypeSafe AI API key is one word: a space or a second line is a bad paste. */
10
+ const ApiKey = Schema.String.pipe(Schema.check(Schema.isPattern(/^\S+$/)));
11
+
12
+ /** What `adhere login` saves. */
13
+ class SavedCredentials extends Schema.Class<SavedCredentials>("SavedCredentials")({
14
+ apiKey: Schema.RedactedFromValue(ApiKey),
15
+ }) {}
16
+
17
+ const SavedCredentialsJson = Schema.fromJsonString(SavedCredentials);
18
+
19
+ /** The TypeSafe AI API key: where a request gets it, and where `adhere login` keeps it. */
20
+ export class Credentials extends Context.Service<
21
+ Credentials,
22
+ {
23
+ /** `TYPESAFE_API_KEY` when it is set, otherwise the key `adhere login` saved. */
24
+ readonly apiKey: Effect.Effect<Redacted.Redacted<string>, CredentialsUnavailable>;
25
+ /** Where the saved key lives, whether or not there is one. */
26
+ readonly file: Effect.Effect<string, CredentialsUnavailable>;
27
+ /** Saves a key for later runs, readable only by the user. */
28
+ readonly save: (
29
+ apiKey: Redacted.Redacted<string>,
30
+ ) => Effect.Effect<void, CredentialsUnavailable>;
31
+ /** Deletes the saved key. False when there was none. */
32
+ readonly remove: Effect.Effect<boolean, CredentialsUnavailable>;
33
+ }
34
+ >()("@drkmttr/adhere/services/Credentials") {}
35
+
36
+ const refused = (message: string) => CredentialsUnavailable.make({ message });
37
+
38
+ /**
39
+ * The key lives in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
40
+ * `~/.config` without it, on every platform. The file is written whole to a
41
+ * temporary file created readable only by the user, then renamed over the
42
+ * old one, so no reader sees half a key or a key with looser permissions.
43
+ */
44
+ export const CredentialsLive = Layer.effect(Credentials)(
45
+ Effect.gen(function* () {
46
+ const fs = yield* FileSystem.FileSystem;
47
+ const path = yield* Path.Path;
48
+
49
+ const file = Config.nonEmptyString("XDG_CONFIG_HOME").pipe(
50
+ Config.orElse(() =>
51
+ Config.nonEmptyString("HOME").pipe(
52
+ Config.orElse(() => Config.nonEmptyString("USERPROFILE")),
53
+ Config.map((home) => path.join(home, ".config")),
54
+ ),
55
+ ),
56
+ Config.map((configHome) => path.join(configHome, "adhere", "credentials.json")),
57
+ Effect.mapError(() =>
58
+ refused("No home directory to keep the API key in: set HOME or XDG_CONFIG_HOME."),
59
+ ),
60
+ );
61
+
62
+ const exists = (target: string) =>
63
+ fs
64
+ .exists(target)
65
+ .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
66
+
67
+ const readSaved = Effect.fn("Credentials.readSaved")(function* (target: string) {
68
+ const text = yield* fs
69
+ .readFileString(target)
70
+ .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
71
+ const saved = yield* Schema.decodeUnknownEffect(SavedCredentialsJson)(text).pipe(
72
+ Effect.mapError(() =>
73
+ refused(`${target} holds no API key. Run \`adhere login\` to save one again.`),
74
+ ),
75
+ );
76
+ return saved.apiKey;
77
+ });
78
+
79
+ // Read when a request needs the key, not before: a cached run needs none.
80
+ const apiKey = Effect.fn("Credentials.apiKey")(function* () {
81
+ const fromEnvironment = yield* Config.option(Config.redacted("TYPESAFE_API_KEY")).pipe(
82
+ Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
83
+ );
84
+ if (Option.isSome(fromEnvironment)) return fromEnvironment.value;
85
+ const target = yield* file;
86
+ if (!(yield* exists(target))) {
87
+ return yield* refused(
88
+ "No TypeSafe AI API key. Run `adhere login` to save one, or set TYPESAFE_API_KEY.",
89
+ );
90
+ }
91
+ return yield* readSaved(target);
92
+ });
93
+
94
+ const save = Effect.fn("Credentials.save")(function* (key: Redacted.Redacted<string>) {
95
+ const saved = yield* Schema.decodeUnknownEffect(SavedCredentials)({
96
+ apiKey: Redacted.value(key),
97
+ }).pipe(
98
+ Effect.mapError(() => refused("An API key is one word, with no spaces or line breaks.")),
99
+ );
100
+ const json = yield* Schema.encodeEffect(SavedCredentialsJson)(saved).pipe(Effect.orDie);
101
+ const target = yield* file;
102
+ const temporary = `${target}.tmp`;
103
+ yield* Effect.gen(function* () {
104
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true, mode: 0o700 });
105
+ // A mode applies only when a file is created: never reuse a leftover.
106
+ yield* fs.remove(temporary, { force: true });
107
+ yield* fs.writeFileString(temporary, `${json}\n`, { mode: 0o600 });
108
+ yield* fs.rename(temporary, target);
109
+ }).pipe(Effect.mapError((problem) => refused(`Cannot save ${target}: ${problem.message}`)));
110
+ });
111
+
112
+ const remove = Effect.fn("Credentials.remove")(function* () {
113
+ const target = yield* file;
114
+ if (!(yield* exists(target))) return false;
115
+ yield* fs
116
+ .remove(target)
117
+ .pipe(Effect.mapError((problem) => refused(`Cannot delete ${target}: ${problem.message}`)));
118
+ return true;
119
+ });
120
+
121
+ return Credentials.of({ apiKey: apiKey(), file, save, remove: remove() });
122
+ }),
123
+ );
@@ -1,4 +1,5 @@
1
1
  import { AdhereConfig } from "#services/AdhereConfig.ts";
2
+ import { Credentials } from "#services/Credentials.ts";
2
3
  import {
3
4
  blockBody,
4
5
  Jev,
@@ -9,7 +10,7 @@ import {
9
10
  needsBlocks,
10
11
  type Rules,
11
12
  } from "#services/Jev.ts";
12
- import { Config, Effect, Layer, Record, Schedule, Schema } from "effect";
13
+ import { Effect, Layer, Record, Schedule, Schema } from "effect";
13
14
  import { HttpClient, HttpClientResponse } from "effect/unstable/http";
14
15
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
15
16
 
@@ -27,6 +28,7 @@ const refused = (message: string) => JevUnavailable.make({ message });
27
28
  export const JevLive = Layer.effect(Jev)(
28
29
  Effect.gen(function* () {
29
30
  const config = yield* AdhereConfig;
31
+ const credentials = yield* Credentials;
30
32
  const client = (yield* HttpClient.HttpClient).pipe(
31
33
  HttpClient.filterStatusOk,
32
34
  HttpClient.transformResponse(Effect.timeout("30 seconds")),
@@ -39,8 +41,8 @@ export const JevLive = Layer.effect(Jev)(
39
41
  const ask = <A>(body: unknown, Answers: Schema.Codec<A, unknown, never, never>) =>
40
42
  Effect.gen(function* () {
41
43
  // Read here, not in the layer: a run with nothing pending needs no key.
42
- const apiKey = yield* Config.redacted("TYPESAFE_API_KEY").pipe(
43
- Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
44
+ const apiKey = yield* credentials.apiKey.pipe(
45
+ Effect.mapError((problem) => refused(problem.message)),
44
46
  );
45
47
  const request = yield* HttpClientRequest.bodyJson(
46
48
  HttpClientRequest.post(SYSTEM_ONE),
@@ -1,83 +1,145 @@
1
+ import { type Kind, tokenize } from "#highlight.ts";
1
2
  import type { AuditResult, Finding } from "#workflows/audit.ts";
2
3
 
3
- const displayPath = (file: string, root: string | undefined): string =>
4
- root !== undefined && file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
4
+ /**
5
+ * The report as data: lines of spans, each span a part of the frame or a
6
+ * token of code, named by its role. Layout measures text; only `serialize`
7
+ * turns a role into color, so no width or blank line ever sees an escape code.
8
+ */
9
+ type Role = "error" | "path" | "lineNumber" | "underline" | "label" | Kind;
5
10
 
6
- const counted = (count: number, one: string, many: string): string =>
7
- `${count} ${count === 1 ? one : many}`;
11
+ interface Span {
12
+ readonly text: string;
13
+ readonly role?: Role;
14
+ }
8
15
 
9
- /** Foreground color. `38;5;N` is a palette index, `38;2;R;G;B` is truecolor. A trailing `;1` is bold. */
10
- const paint = (code: string, text: string, enabled: boolean): string =>
11
- enabled ? `\u001b[${code}m${text}\u001b[0m` : text;
16
+ type Line = ReadonlyArray<Span>;
12
17
 
13
- const red = (text: string, enabled: boolean) => paint("38;2;219;91;81;1", text, enabled);
14
- const blue = (text: string, enabled: boolean) => paint("38;2;5;125;160;1", text, enabled);
15
- const dim = (text: string, enabled: boolean) => paint("2", text, enabled);
16
- const pink = (text: string, enabled: boolean) => paint("38;2;255;0;175", text, enabled);
17
- const helpTint = (text: string, enabled: boolean) => paint("38;2;180;105;245", text, enabled);
18
+ /**
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.
23
+ */
24
+ const THEME: Readonly<Record<Role, string>> = {
25
+ error: "38;2;219;91;81;1",
26
+ path: "38;2;5;125;160;1",
27
+ lineNumber: "2",
28
+ underline: "38;2;255;0;175",
29
+ label: "38;2;180;105;245",
30
+ comment: "2",
31
+ string: "32",
32
+ constant: "33",
33
+ keyword: "34",
34
+ type: "36",
35
+ };
18
36
 
19
- export interface RenderOptions {
20
- /** Color the frame the way `vp lint` does on a terminal. */
21
- readonly color?: boolean;
22
- readonly root?: string;
23
- }
37
+ const span = (text: string, role?: Role): Span => (role === undefined ? { text } : { text, role });
38
+
39
+ const isBlank = (line: Line): boolean => line.every((part) => part.text.trim().length === 0);
40
+
41
+ /** A line as text, colored by role on a terminal. Whitespace has no color to show. */
42
+ const serialize = (line: Line, color: boolean): string =>
43
+ line
44
+ .map(({ text, role }) =>
45
+ color && role !== undefined && text.trim().length > 0
46
+ ? `\u001b[${THEME[role]}m${text}\u001b[0m`
47
+ : text,
48
+ )
49
+ .join("");
50
+
51
+ /** The label, then the first line; the other lines indented to line up under it. */
52
+ const hanging = (label: Span, lines: ReadonlyArray<Line>): ReadonlyArray<Line> => {
53
+ const [first = [], ...rest] = lines;
54
+ const indent = span(" ".repeat(label.text.length));
55
+ return [[label, ...first], ...rest.map((line) => (isBlank(line) ? [] : [indent, ...line]))];
56
+ };
24
57
 
25
- const isBlank = (line: string): boolean => line.trim().length === 0;
58
+ /** Code as lines of spans, each token in the role of its kind. */
59
+ const highlighted = (code: string): ReadonlyArray<Line> =>
60
+ tokenize(code).map((tokens) => tokens.map(({ text, kind }) => span(text, kind)));
26
61
 
27
- const codeLines = (code: string): ReadonlyArray<string> => {
28
- const lines = code.split("\n");
62
+ /** Code as highlighted lines, without the blank lines at either end. */
63
+ const codeLines = (code: string): ReadonlyArray<Line> => {
64
+ const lines = highlighted(code);
29
65
  const first = lines.findIndex((line) => !isBlank(line));
30
- if (first < 0) return [];
31
- let last = lines.length - 1;
32
- while (last > first && isBlank(lines[last] ?? "")) last--;
33
- return lines.slice(first, last + 1);
66
+ const last = lines.findLastIndex((line) => !isBlank(line));
67
+ return first < 0 ? [] : lines.slice(first, last + 1);
34
68
  };
35
69
 
36
- /**
37
- * One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
38
- * the offending line, a pink underline, and the reference code as the hint.
39
- * A rule without a reference shows its code to avoid instead, labeled so.
40
- */
41
- const frame = (finding: Finding, options: RenderOptions): ReadonlyArray<string> => {
42
- const color = options.color === true;
43
- const digits = String(finding.line);
44
- const gutter = " ".repeat(digits.length + 2);
45
- const [label, code] =
46
- finding.reference === undefined ? ["avoid", finding.avoid ?? ""] : ["hint", finding.reference];
47
- const prefix = ` ${label}: `;
48
- const [hint = "", ...rest] = codeLines(code);
49
- const header = `${finding.rule} (${finding.probability.toFixed(2)})`;
70
+ const displayPath = (file: string, root: string | undefined): string =>
71
+ root !== undefined && file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
72
+
73
+ const counted = (count: number, one: string, many: string): string =>
74
+ `${count} ${count === 1 ? one : many}`;
75
+
76
+ const header = (finding: Finding): Line => [
77
+ span(" "),
78
+ span("×", "error"),
79
+ span(" "),
80
+ span(`${finding.rule} (${finding.probability.toFixed(2)})`, "error"),
81
+ span(": "),
82
+ span(finding.description, "error"),
83
+ ];
84
+
85
+ /** The path, the offending line numbered in the gutter, and an underline as long as the line. */
86
+ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
87
+ const number = String(finding.line);
88
+ const gutter = span(" ".repeat(number.length + 2));
89
+ // A snippet is one line: `snippetOf` trims it out of the file.
90
+ const [code = []] = highlighted(finding.snippet);
91
+ const underline = "─".repeat(Math.max(1, finding.snippet.trim().length));
50
92
  return [
51
- ` ${red("×", color)} ${red(header, color)}: ${red(finding.description, color)}`,
52
- `${gutter}╭─[${blue(displayPath(finding.file, options.root), color)}:${finding.line}:1]`,
53
- ` ${dim(digits, color)} │ ${finding.snippet}`,
54
- `${gutter}· ${pink("─".repeat(Math.max(1, finding.snippet.trim().length)), color)}`,
55
- `${gutter}╰────`,
56
- `${helpTint(prefix, color)}${hint}`,
57
- ...rest.map((line) => (isBlank(line) ? "" : `${" ".repeat(prefix.length)}${line}`)),
93
+ [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(`:${number}:1]`)],
94
+ [span(" "), span(number, "lineNumber"), span(" │ "), ...code],
95
+ [gutter, span("· "), span(underline, "underline")],
96
+ [gutter, span("╰────")],
58
97
  ];
59
98
  };
60
99
 
100
+ /** The reference as the hint. A rule without one shows its code to avoid instead, labeled so. */
101
+ const hint = (finding: Finding): ReadonlyArray<Line> => {
102
+ const [label, code] =
103
+ finding.reference === undefined ? ["avoid", finding.avoid ?? ""] : ["hint", finding.reference];
104
+ return hanging(span(` ${label}: `, "label"), codeLines(code));
105
+ };
106
+
61
107
  /**
62
- * The report, in the default `vp lint` layout. Diagnostics are separated by
63
- * a blank line. Color is on when the caller is writing to a terminal.
108
+ * One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
109
+ * the offending line, a pink underline, and the reference code as the hint.
64
110
  */
65
- export const render = (result: AuditResult, options: RenderOptions = {}): ReadonlyArray<string> => {
66
- const lines: Array<string> = [];
67
- for (const finding of result.findings) {
68
- if (lines.length > 0) lines.push("");
69
- lines.push(...frame(finding, options));
70
- }
71
- if (lines.length > 0) lines.push("");
72
- const summary = [
111
+ const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
112
+ header(finding),
113
+ ...excerpt(finding, root),
114
+ ...hint(finding),
115
+ ];
116
+
117
+ const summary = (result: AuditResult): ReadonlyArray<Line> => {
118
+ const counts = [
73
119
  counted(result.files, "file", "files"),
74
120
  `${result.judged} judged`,
75
121
  `${result.cached} cached`,
76
122
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
77
123
  ];
78
- lines.push(
79
- `Found ${counted(result.findings.length, "error", "errors")}.`,
80
- `${summary.join(", ")}.`,
81
- );
82
- return lines;
124
+ return [
125
+ [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
126
+ [span(`${counts.join(", ")}.`)],
127
+ ];
83
128
  };
129
+
130
+ export interface RenderOptions {
131
+ /** Color the frame the way `vp lint` does on a terminal, and highlight the code. */
132
+ readonly color?: boolean;
133
+ readonly root?: string;
134
+ }
135
+
136
+ /**
137
+ * The report, in the default `vp lint` layout: each diagnostic followed by a
138
+ * blank line, then the counts. Color is on when the caller is writing to a
139
+ * terminal.
140
+ */
141
+ export const render = (result: AuditResult, options: RenderOptions = {}): ReadonlyArray<string> =>
142
+ [
143
+ ...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
144
+ ...summary(result),
145
+ ].map((line) => serialize(line, options.color === true));