@drkmttr/adhere 0.3.1 → 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 +50 -18
- package/package.json +6 -6
- package/skills/adhere/SKILL.md +13 -5
- package/src/cli.ts +71 -3
- package/src/config.ts +11 -2
- package/src/highlight.ts +106 -0
- package/src/init.ts +10 -0
- package/src/markdown.ts +42 -10
- package/src/services/Credentials.ts +123 -0
- package/src/services/Jev.http.ts +5 -3
- package/src/services/Jev.ts +33 -9
- package/src/workflows/audit.ts +17 -2
- package/src/workflows/format.ts +122 -56
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# adhere
|
|
2
2
|
|
|
3
|
-
A linter for rules a normal linter cannot check. A rule is a
|
|
4
|
-
reference code
|
|
5
|
-
asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
|
-
file
|
|
3
|
+
A linter for rules a normal linter cannot check. A rule is a one-sentence
|
|
4
|
+
description plus correct reference code, code to avoid, or both. For each
|
|
5
|
+
source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
|
+
model) whether the file breaks each rule, gets a calibrated probability per
|
|
7
7
|
rule, and reports the ones above a threshold with the line Jev points at. The
|
|
8
8
|
report uses the same frame as `vp lint`.
|
|
9
9
|
|
|
@@ -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
|
|
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
|
-
|
|
35
|
+
adhere login # save your TypeSafe AI API key, once
|
|
35
36
|
adhere lint # audit the working directory
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -54,10 +55,12 @@ Found 1 error.
|
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
The header is the rule id, Jev's probability, and the rule's description. The
|
|
57
|
-
hint is the rule's reference
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
hint is the rule's reference; a rule with only code to avoid shows that code,
|
|
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.
|
|
61
64
|
|
|
62
65
|
## Usage
|
|
63
66
|
|
|
@@ -67,6 +70,8 @@ adhere lint --preset effect # add a built-in rule set; the config becomes opt
|
|
|
67
70
|
adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
|
|
68
71
|
adhere validate # load the config and rules without Jev, check for contradictions
|
|
69
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
|
|
70
75
|
adhere skill # print the agent skill (below)
|
|
71
76
|
```
|
|
72
77
|
|
|
@@ -102,6 +107,15 @@ opposite textual directives for the same topic, such as "Use service classes
|
|
|
102
107
|
for IO" and "Do not use service classes for IO". Same-id nested rules are
|
|
103
108
|
treated as intentional shadowing rather than contradictions.
|
|
104
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
|
+
|
|
105
119
|
## Config
|
|
106
120
|
|
|
107
121
|
A config file is optional. Without one, adhere reads the rule files in
|
|
@@ -128,6 +142,7 @@ export default {
|
|
|
128
142
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
129
143
|
type UserId = typeof UserId.Type
|
|
130
144
|
`,
|
|
145
|
+
avoid: "type UserId = string", // optional: what a violation looks like
|
|
131
146
|
threshold: 0.8, // optional per-rule override
|
|
132
147
|
},
|
|
133
148
|
},
|
|
@@ -154,7 +169,8 @@ directory exists, it is read without any config.
|
|
|
154
169
|
|
|
155
170
|
A file is front matter, then a body. The first fenced code block in the body
|
|
156
171
|
is the reference; prose around it renders on GitHub and is ignored. Without a
|
|
157
|
-
fence, the whole body is the reference.
|
|
172
|
+
fence, the whole body is the reference. A fence tagged `avoid` after its
|
|
173
|
+
language holds code to avoid instead: what a violation looks like.
|
|
158
174
|
|
|
159
175
|
````md
|
|
160
176
|
---
|
|
@@ -170,11 +186,25 @@ const Port = Schema.Int.pipe(
|
|
|
170
186
|
Schema.brand("Port"),
|
|
171
187
|
);
|
|
172
188
|
```
|
|
189
|
+
|
|
190
|
+
Not this:
|
|
191
|
+
|
|
192
|
+
```ts avoid
|
|
193
|
+
const port: number = Number(process.env.PORT);
|
|
194
|
+
```
|
|
173
195
|
````
|
|
174
196
|
|
|
175
|
-
`description` is required
|
|
176
|
-
validation refuses the run with its
|
|
177
|
-
from the package root does the same
|
|
197
|
+
`description` is required, with a reference, code to avoid, or both.
|
|
198
|
+
`threshold` is optional. A file that fails validation refuses the run with its
|
|
199
|
+
path in the message. `loadRules(directory)` from the package root does the same
|
|
200
|
+
load for your own tooling.
|
|
201
|
+
|
|
202
|
+
With a reference, Jev is asked whether the file diverges from it, and code to
|
|
203
|
+
avoid, when there is some, is its example of diverging. With only code to
|
|
204
|
+
avoid, Jev is asked whether the file contains it. That suits a rule with no
|
|
205
|
+
single correct form to show, such as a hand-rolled retry loop or an error
|
|
206
|
+
caught and dropped. Neither goes in the other's place: code to avoid in the
|
|
207
|
+
reference reads to Jev as the pattern to follow.
|
|
178
208
|
|
|
179
209
|
Nested `.adhere/` directories are also discovered, except under
|
|
180
210
|
`node_modules/`, `dist/`, and the other skipped directories (above). A rule in
|
|
@@ -221,8 +251,10 @@ beats all of the above for that rule.
|
|
|
221
251
|
## How a file is judged
|
|
222
252
|
|
|
223
253
|
1. One Jev request per file, with the file's numbered lines and every rule as
|
|
224
|
-
the state, and one `noul` (yes/no probability) question per rule
|
|
225
|
-
|
|
254
|
+
the state, and one `noul` (yes/no probability) question per rule: whether
|
|
255
|
+
the file diverges from the reference or, for a rule with only code to
|
|
256
|
+
avoid, whether it contains that code. Every question shares the state cost
|
|
257
|
+
of the request.
|
|
226
258
|
2. A second request only when at least one rule's probability is above its
|
|
227
259
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
228
260
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
@@ -239,8 +271,8 @@ only that rule. A lowered threshold locates cached judgments that are newly
|
|
|
239
271
|
above it without judging again. Entries depend on content, not on the
|
|
240
272
|
machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
|
|
241
273
|
|
|
242
|
-
|
|
243
|
-
|
|
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.
|
|
244
276
|
|
|
245
277
|
## Agent skill
|
|
246
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "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.
|
|
71
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
72
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
74
|
-
"@drkmttr/adhere-win32-x64": "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
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -61,15 +61,22 @@ Optional prose for readers on GitHub. adhere ignores it.
|
|
|
61
61
|
```ts
|
|
62
62
|
// Correct code lifted from this repo, trimmed to the pattern.
|
|
63
63
|
```
|
|
64
|
+
|
|
65
|
+
```ts avoid
|
|
66
|
+
// Optional: the incorrect form this rule catches, as it appeared in the repo.
|
|
67
|
+
```
|
|
64
68
|
````
|
|
65
69
|
|
|
66
70
|
Rules for the rule:
|
|
67
71
|
|
|
68
72
|
- `description` is one sentence, positive, specific. Jev reads it with the
|
|
69
73
|
reference; vague words ("properly", "correctly") give it nothing to judge.
|
|
70
|
-
- The reference is real code from the repo, not invented
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
- The reference is real code from the repo, not invented, and only correct
|
|
75
|
+
code: Jev compares files to it, so incorrect code there teaches the wrong
|
|
76
|
+
thing. Incorrect code goes in a fence tagged `avoid`, which Jev reads as what
|
|
77
|
+
a violation looks like. Add one when violations have a recognizable shape,
|
|
78
|
+
ideally one found in the repo's history. A rule with no single correct form
|
|
79
|
+
can be only an `avoid` block.
|
|
73
80
|
- One pattern per file. Two patterns in one reference blur the probability.
|
|
74
81
|
- `threshold` is optional. Start without it; set it in step 5 if needed.
|
|
75
82
|
|
|
@@ -89,8 +96,9 @@ export default {
|
|
|
89
96
|
} satisfies Config;
|
|
90
97
|
```
|
|
91
98
|
|
|
92
|
-
Add `.adhere/cache/` to `.gitignore`.
|
|
93
|
-
|
|
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`.
|
|
94
102
|
|
|
95
103
|
## 5. Run and calibrate
|
|
96
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([
|
|
210
|
+
Command.withSubcommands([
|
|
211
|
+
lintCommand,
|
|
212
|
+
validateCommand,
|
|
213
|
+
initCommand,
|
|
214
|
+
loginCommand,
|
|
215
|
+
logoutCommand,
|
|
216
|
+
skillCommand,
|
|
217
|
+
]),
|
|
150
218
|
);
|
package/src/config.ts
CHANGED
|
@@ -6,9 +6,18 @@ export type RuleId = string;
|
|
|
6
6
|
|
|
7
7
|
export const Rule = Schema.Struct({
|
|
8
8
|
description: Schema.String,
|
|
9
|
-
|
|
9
|
+
/** Correct code: the pattern a file should follow. */
|
|
10
|
+
reference: Schema.optionalKey(Schema.String),
|
|
11
|
+
/** Incorrect code: what a violation looks like. */
|
|
12
|
+
avoid: Schema.optionalKey(Schema.String),
|
|
10
13
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
11
|
-
})
|
|
14
|
+
}).check(
|
|
15
|
+
Schema.makeFilter((rule) =>
|
|
16
|
+
rule.reference !== undefined || rule.avoid !== undefined
|
|
17
|
+
? undefined
|
|
18
|
+
: "a rule needs a reference, code to avoid, or both",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
12
21
|
export interface Rule extends Schema.Schema.Type<typeof Rule> {}
|
|
13
22
|
|
|
14
23
|
export type Rules = Readonly<Record<RuleId, Rule>>;
|
package/src/highlight.ts
ADDED
|
@@ -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
|
+
};
|
package/src/init.ts
CHANGED
|
@@ -43,6 +43,16 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
|
|
|
43
43
|
return yield* CustomerStore.find(customerId);
|
|
44
44
|
});
|
|
45
45
|
\`\`\`
|
|
46
|
+
|
|
47
|
+
A fence tagged \`avoid\` shows what a violation looks like. It is optional, and
|
|
48
|
+
a rule can have one without the block above.
|
|
49
|
+
|
|
50
|
+
\`\`\`ts avoid
|
|
51
|
+
export const handle = (id: string) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
return yield* CustomerStore.find(id);
|
|
54
|
+
});
|
|
55
|
+
\`\`\`
|
|
46
56
|
`;
|
|
47
57
|
|
|
48
58
|
const CONFIG_PATH = CONFIG_FILES[0];
|
package/src/markdown.ts
CHANGED
|
@@ -14,8 +14,13 @@ import { Effect, Schema } from "effect";
|
|
|
14
14
|
* const reference = "the correct code"
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* ```ts avoid
|
|
18
|
+
* const avoided = "what a violation looks like"
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The reference is the first fenced code block; `avoid` in a fence's info
|
|
22
|
+
* string marks the code to avoid instead. A rule has either or both. Without a
|
|
23
|
+
* fence, the whole body is the reference. `threshold` is optional.
|
|
19
24
|
*/
|
|
20
25
|
const FrontMatter = Schema.Struct({
|
|
21
26
|
description: Schema.String,
|
|
@@ -23,7 +28,8 @@ const FrontMatter = Schema.Struct({
|
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
26
|
-
const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]
|
|
31
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
|
|
32
|
+
const AVOID = "avoid";
|
|
27
33
|
|
|
28
34
|
const unquote = (value: string): string => {
|
|
29
35
|
const trimmed = value.trim();
|
|
@@ -44,9 +50,35 @@ const parseFrontMatter = (block: string): Record<string, string> => {
|
|
|
44
50
|
return fields;
|
|
45
51
|
};
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
interface Fence {
|
|
54
|
+
readonly avoid: boolean;
|
|
55
|
+
readonly code: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fencesOf = (body: string): ReadonlyArray<Fence> =>
|
|
59
|
+
Array.from(body.matchAll(FENCE), (match) => ({
|
|
60
|
+
avoid: (match[2] ?? "")
|
|
61
|
+
.trim()
|
|
62
|
+
.split(/\s+/)
|
|
63
|
+
.some((word) => word.toLowerCase() === AVOID),
|
|
64
|
+
code: (match[3] ?? "").trim(),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
/** An empty block counts as no block. */
|
|
68
|
+
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
69
|
+
code === undefined || code.length === 0 ? undefined : code;
|
|
70
|
+
|
|
71
|
+
/** The reference and the code to avoid, each left out when the body has none. */
|
|
72
|
+
const codeOf = (body: string): Pick<Rule, "reference" | "avoid"> => {
|
|
73
|
+
const fences = fencesOf(body);
|
|
74
|
+
const reference = nonEmpty(
|
|
75
|
+
fences.length === 0 ? body.trim() : fences.find((fence) => !fence.avoid)?.code,
|
|
76
|
+
);
|
|
77
|
+
const avoid = nonEmpty(fences.find((fence) => fence.avoid)?.code);
|
|
78
|
+
return {
|
|
79
|
+
...(reference === undefined ? {} : { reference }),
|
|
80
|
+
...(avoid === undefined ? {} : { avoid }),
|
|
81
|
+
};
|
|
50
82
|
};
|
|
51
83
|
|
|
52
84
|
export const parseRuleMarkdown = (
|
|
@@ -65,11 +97,11 @@ export const parseRuleMarkdown = (
|
|
|
65
97
|
ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
|
|
66
98
|
),
|
|
67
99
|
);
|
|
68
|
-
const
|
|
69
|
-
if (reference.
|
|
100
|
+
const code = codeOf(match[2]);
|
|
101
|
+
if (code.reference === undefined && code.avoid === undefined) {
|
|
70
102
|
return yield* ConfigUnavailable.make({
|
|
71
|
-
message: `${file}: the body
|
|
103
|
+
message: `${file}: the body needs reference code, a fence tagged avoid, or both`,
|
|
72
104
|
});
|
|
73
105
|
}
|
|
74
|
-
return { ...front,
|
|
106
|
+
return { ...front, ...code };
|
|
75
107
|
});
|
|
@@ -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
|
+
);
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -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 {
|
|
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*
|
|
43
|
-
Effect.mapError((problem) => refused(
|
|
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),
|
package/src/services/Jev.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuleId, Rules } from "#config.ts";
|
|
1
|
+
import type { Rule, RuleId, Rules } from "#config.ts";
|
|
2
2
|
import { Context, type Effect, Record, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
export type { Rules };
|
|
@@ -36,12 +36,36 @@ export const needsBlocks = (lines: Lines): boolean =>
|
|
|
36
36
|
|
|
37
37
|
const stateOf = (lines: Lines, rules: Rules) => ({
|
|
38
38
|
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
39
|
-
rules: Record.map(rules, ({ description, reference }) => ({
|
|
39
|
+
rules: Record.map(rules, ({ description, reference, avoid }) => ({
|
|
40
40
|
description,
|
|
41
|
-
reference,
|
|
41
|
+
...(reference === undefined ? {} : { reference }),
|
|
42
|
+
...(avoid === undefined ? {} : { avoid }),
|
|
42
43
|
})),
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
|
|
47
|
+
`state.rules["${id}"].${key}`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A rule with a reference asks whether the file diverges from it, with the
|
|
51
|
+
* code to avoid as an example when there is some. A rule without one asks
|
|
52
|
+
* whether the file contains the code to avoid.
|
|
53
|
+
*/
|
|
54
|
+
const judgeInstructions = (id: RuleId, rule: Rule): string =>
|
|
55
|
+
rule.reference === undefined
|
|
56
|
+
? `Does state.code contain the pattern shown in ${field(id, "avoid")}, which ${field(id, "description")} rules out? Answer no if nothing in this file resembles it.`
|
|
57
|
+
: `Does state.code diverge from the pattern shown in ${field(id, "reference")}${
|
|
58
|
+
rule.avoid === undefined ? "" : `, for example by doing what ${field(id, "avoid")} shows`
|
|
59
|
+
}, as described by ${field(id, "description")}? Answer no if the pattern does not apply to this file.`;
|
|
60
|
+
|
|
61
|
+
/** What the offending line does, for the line and block questions. */
|
|
62
|
+
const offense = (id: RuleId, rule: Rule): string =>
|
|
63
|
+
rule.reference === undefined
|
|
64
|
+
? `shows the pattern in ${field(id, "avoid")}`
|
|
65
|
+
: rule.avoid === undefined
|
|
66
|
+
? `diverges from ${field(id, "reference")}`
|
|
67
|
+
: `diverges from ${field(id, "reference")} or resembles ${field(id, "avoid")}`;
|
|
68
|
+
|
|
45
69
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
46
70
|
const criteria: Record<string, string> = {};
|
|
47
71
|
for (let n = from; n <= Math.min(to, lines.length); n++) {
|
|
@@ -54,9 +78,9 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
54
78
|
export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
|
|
55
79
|
model,
|
|
56
80
|
state: stateOf(lines, rules),
|
|
57
|
-
questions: Record.map(rules, (
|
|
81
|
+
questions: Record.map(rules, (rule, id) => ({
|
|
58
82
|
type: "noul" as const,
|
|
59
|
-
instructions:
|
|
83
|
+
instructions: judgeInstructions(id, rule),
|
|
60
84
|
})),
|
|
61
85
|
});
|
|
62
86
|
|
|
@@ -68,11 +92,11 @@ export const locateBody = (
|
|
|
68
92
|
) => ({
|
|
69
93
|
model,
|
|
70
94
|
state: stateOf(lines, rules),
|
|
71
|
-
questions: Record.map(rules, (
|
|
95
|
+
questions: Record.map(rules, (rule, id) => {
|
|
72
96
|
const block = blocks?.[id];
|
|
73
97
|
return {
|
|
74
98
|
type: "choice" as const,
|
|
75
|
-
instructions: `Which line of state.code most clearly
|
|
99
|
+
instructions: `Which line of state.code most clearly ${offense(id, rule)}?`,
|
|
76
100
|
criteria:
|
|
77
101
|
block === undefined
|
|
78
102
|
? lineCriteria(lines, 1, lines.length)
|
|
@@ -92,9 +116,9 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
92
116
|
return {
|
|
93
117
|
model,
|
|
94
118
|
state: stateOf(lines, rules),
|
|
95
|
-
questions: Record.map(rules, (
|
|
119
|
+
questions: Record.map(rules, (rule, id) => ({
|
|
96
120
|
type: "choice" as const,
|
|
97
|
-
instructions: `Which block of state.code contains the line that most clearly
|
|
121
|
+
instructions: `Which block of state.code contains the line that most clearly ${offense(id, rule)}?`,
|
|
98
122
|
criteria,
|
|
99
123
|
})),
|
|
100
124
|
};
|
package/src/workflows/audit.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { type Crypto, Effect, Record } from "effect";
|
|
|
10
10
|
export interface Finding {
|
|
11
11
|
readonly rule: RuleId;
|
|
12
12
|
readonly description: string;
|
|
13
|
-
readonly reference
|
|
13
|
+
readonly reference?: string;
|
|
14
|
+
readonly avoid?: string;
|
|
14
15
|
readonly file: string;
|
|
15
16
|
readonly line: number;
|
|
16
17
|
readonly snippet: string;
|
|
@@ -43,6 +44,19 @@ const fileResult = (
|
|
|
43
44
|
|
|
44
45
|
const isEmpty = Record.isEmptyReadonlyRecord;
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What a judgment depends on besides the file. A rule without code to avoid
|
|
49
|
+
* hashes what every rule hashed before `avoid` existed, so its cached
|
|
50
|
+
* judgments survive the upgrade.
|
|
51
|
+
*/
|
|
52
|
+
const fingerprintOf = (model: string, rule: Rule) =>
|
|
53
|
+
sha256(
|
|
54
|
+
model +
|
|
55
|
+
rule.description +
|
|
56
|
+
(rule.reference ?? "") +
|
|
57
|
+
(rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
|
|
58
|
+
);
|
|
59
|
+
|
|
46
60
|
export const runAudit: Effect.Effect<
|
|
47
61
|
AuditResult,
|
|
48
62
|
WalkUnavailable | JevUnavailable,
|
|
@@ -66,7 +80,7 @@ export const runAudit: Effect.Effect<
|
|
|
66
80
|
Object.entries(configuredRules(file.path)),
|
|
67
81
|
([id, rule]) =>
|
|
68
82
|
Effect.map(
|
|
69
|
-
|
|
83
|
+
fingerprintOf(config.model, rule),
|
|
70
84
|
(fingerprint) =>
|
|
71
85
|
[
|
|
72
86
|
id,
|
|
@@ -116,6 +130,7 @@ export const runAudit: Effect.Effect<
|
|
|
116
130
|
rule: id,
|
|
117
131
|
description: k.rule.description,
|
|
118
132
|
reference: k.rule.reference,
|
|
133
|
+
avoid: k.rule.avoid,
|
|
119
134
|
file: file.path,
|
|
120
135
|
line: judgment.line,
|
|
121
136
|
snippet: judgment.snippet ?? "",
|
package/src/workflows/format.ts
CHANGED
|
@@ -1,79 +1,145 @@
|
|
|
1
|
+
import { type Kind, tokenize } from "#highlight.ts";
|
|
1
2
|
import type { AuditResult, Finding } from "#workflows/audit.ts";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
11
|
+
interface Span {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly role?: Role;
|
|
14
|
+
}
|
|
8
15
|
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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 });
|
|
24
38
|
|
|
25
|
-
const isBlank = (line:
|
|
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
|
+
};
|
|
26
57
|
|
|
27
|
-
|
|
28
|
-
|
|
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)));
|
|
61
|
+
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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));
|
|
46
92
|
return [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`${gutter}╰────`,
|
|
52
|
-
`${helpTint(" hint: ", color)}${hint}`,
|
|
53
|
-
...rest.map((line) => (isBlank(line) ? "" : ` ${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("╰────")],
|
|
54
97
|
];
|
|
55
98
|
};
|
|
56
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
|
+
|
|
57
107
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
60
110
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
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 = [
|
|
69
119
|
counted(result.files, "file", "files"),
|
|
70
120
|
`${result.judged} judged`,
|
|
71
121
|
`${result.cached} cached`,
|
|
72
122
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
73
123
|
];
|
|
74
|
-
|
|
75
|
-
`Found ${counted(result.findings.length, "error", "errors")}
|
|
76
|
-
`${
|
|
77
|
-
|
|
78
|
-
return lines;
|
|
124
|
+
return [
|
|
125
|
+
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
126
|
+
[span(`${counts.join(", ")}.`)],
|
|
127
|
+
];
|
|
79
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));
|