@drkmttr/adhere 0.4.0 → 0.5.0
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 +34 -17
- package/package.json +6 -6
- package/skills/adhere/SKILL.md +4 -3
- package/src/cli.ts +84 -8
- package/src/contradictions.ts +51 -57
- package/src/highlight.ts +106 -0
- package/src/services/Credentials.ts +123 -0
- package/src/services/Jev.http.ts +34 -4
- package/src/services/Jev.ts +129 -5
- package/src/workflows/format.ts +122 -60
package/README.md
CHANGED
|
@@ -23,15 +23,16 @@ 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
|
|
|
30
31
|
```sh
|
|
31
32
|
adhere init # .adhere/config.ts and two example rules
|
|
32
|
-
adhere validate # check the config and rules, without Jev
|
|
33
33
|
echo ".adhere/cache/" >> .gitignore
|
|
34
|
-
|
|
34
|
+
adhere login # save your TypeSafe AI API key, once
|
|
35
|
+
adhere validate # check the rules, and ask Jev whether any contradict
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
|
@@ -66,8 +68,10 @@ flag; a refusal prints its reason.
|
|
|
66
68
|
adhere lint # audit the working directory
|
|
67
69
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
68
70
|
adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
|
|
69
|
-
adhere validate # load the config and rules
|
|
71
|
+
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
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
|
|
|
@@ -95,13 +99,26 @@ kept, and no second one is added, even with `--force`.
|
|
|
95
99
|
### Validate
|
|
96
100
|
|
|
97
101
|
`adhere validate` loads the config, rule files, and presets the way `lint`
|
|
98
|
-
does,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
does, so a file that fails to decode refuses here too, and it prints how many
|
|
103
|
+
rules it loaded. `--preset` adds a built-in rule set, as for `lint`. It then
|
|
104
|
+
asks Jev whether any two rules that apply to the same files contradict, so that
|
|
105
|
+
no code can follow both: one request names, per rule, the rule it conflicts
|
|
106
|
+
with, if any, and each named pair then gets a probability from a request of
|
|
107
|
+
its own, holding only those two rules, since other rules beside them dilute
|
|
108
|
+
the judgment. A pair above
|
|
109
|
+
the threshold is printed with both rule files, and the exit code is 1. Two
|
|
110
|
+
rules with the same id are not compared: a nested rule that shares an id
|
|
111
|
+
shadows the other on purpose. When no two rules share files nothing is sent;
|
|
112
|
+
otherwise `validate` needs the API key, as `lint` does.
|
|
113
|
+
|
|
114
|
+
### Login
|
|
115
|
+
|
|
116
|
+
`adhere login` prompts for a TypeSafe AI API key, masking what you type, and
|
|
117
|
+
saves it to `~/.config/adhere/credentials.json`, or under `$XDG_CONFIG_HOME`
|
|
118
|
+
when that is set, readable only by you. Piped input is read instead of a
|
|
119
|
+
prompt: `adhere login < key.txt`. `lint` and `validate` use the saved key, but
|
|
120
|
+
`TYPESAFE_API_KEY`, when set, takes precedence, so CI can pass a key without a
|
|
121
|
+
login. `adhere logout` deletes the saved key.
|
|
105
122
|
|
|
106
123
|
## Config
|
|
107
124
|
|
|
@@ -258,8 +275,8 @@ only that rule. A lowered threshold locates cached judgments that are newly
|
|
|
258
275
|
above it without judging again. Entries depend on content, not on the
|
|
259
276
|
machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
The API key is read only when a request is about to be sent. A run where
|
|
279
|
+
every file is cached needs no key and no network.
|
|
263
280
|
|
|
264
281
|
## Agent skill
|
|
265
282
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.5.0",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.5.0",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.5.0",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.5.0",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.5.0"
|
|
75
75
|
}
|
|
76
76
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -96,13 +96,14 @@ export default {
|
|
|
96
96
|
} satisfies Config;
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
Add `.adhere/cache/` to `.gitignore`.
|
|
100
|
-
|
|
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
|
|
|
104
105
|
```sh
|
|
105
|
-
adhere validate # decodes the config and rules,
|
|
106
|
+
adhere validate # decodes the config and rules, asks Jev about contradictions
|
|
106
107
|
adhere lint # or: adhere lint --preset effect
|
|
107
108
|
adhere lint --threshold 0.9 # re-reads the cache, sends nothing
|
|
108
109
|
```
|
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. */
|
|
@@ -91,16 +94,24 @@ export const lintCommand = Command.make("lint", { preset, threshold }, () =>
|
|
|
91
94
|
),
|
|
92
95
|
);
|
|
93
96
|
|
|
97
|
+
/** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
|
|
98
|
+
export const validateLayer = (overrides: Overrides) =>
|
|
99
|
+
JevLive.pipe(
|
|
100
|
+
Layer.provide([FetchHttpClient.layer, CredentialsLive]),
|
|
101
|
+
Layer.provideMerge(AdhereConfigLive(overrides)),
|
|
102
|
+
);
|
|
103
|
+
|
|
94
104
|
/**
|
|
95
|
-
* `adhere validate`: everything `lint` loads,
|
|
96
|
-
*
|
|
105
|
+
* `adhere validate`: everything `lint` loads, then Jev on whether any two rules
|
|
106
|
+
* that apply to the same files contradict. A config, rule file, or preset that
|
|
107
|
+
* does not decode refuses here the way it would refuse `lint`.
|
|
97
108
|
*/
|
|
98
109
|
export const validateCommand = Command.make("validate", { preset }, () =>
|
|
99
110
|
Effect.gen(function* () {
|
|
100
111
|
const config = yield* AdhereConfig;
|
|
101
112
|
const path = yield* Path.Path;
|
|
102
113
|
const entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
|
|
103
|
-
const contradictions = findContradictions(entries);
|
|
114
|
+
const contradictions = yield* findContradictions(entries, config.threshold);
|
|
104
115
|
const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
|
|
105
116
|
yield* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
|
|
106
117
|
if (contradictions.length > 0) {
|
|
@@ -109,9 +120,9 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
109
120
|
}),
|
|
110
121
|
).pipe(
|
|
111
122
|
Command.withDescription(
|
|
112
|
-
"Load the config, rule files, and presets the way lint does,
|
|
123
|
+
"Load the config, rule files, and presets the way lint does, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
113
124
|
),
|
|
114
|
-
Command.provide((input) =>
|
|
125
|
+
Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
|
|
115
126
|
);
|
|
116
127
|
|
|
117
128
|
/** `adhere init`: the scaffold. */
|
|
@@ -127,6 +138,63 @@ export const initCommand = Command.make("init", { force }, (input) =>
|
|
|
127
138
|
}),
|
|
128
139
|
).pipe(Command.withDescription("Scaffold .adhere/config.ts and example Markdown rules."));
|
|
129
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
|
|
143
|
+
* Whitespace around it, like the newline a pipe ends with, is dropped.
|
|
144
|
+
*/
|
|
145
|
+
const readApiKey = Effect.fn("login.readApiKey")(function* () {
|
|
146
|
+
const stdio = yield* Stdio.Stdio;
|
|
147
|
+
const typed: string = (yield* stdio.stdinIsTerminal)
|
|
148
|
+
? Redacted.value(yield* Prompt.run(Prompt.password({ message: "TypeSafe AI API key" })))
|
|
149
|
+
: yield* stdio.stdin.pipe(
|
|
150
|
+
Stream.decodeText(),
|
|
151
|
+
Stream.mkString,
|
|
152
|
+
Effect.mapError((problem) =>
|
|
153
|
+
CredentialsUnavailable.make({
|
|
154
|
+
message: `Cannot read the key from stdin: ${problem.message}`,
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
const key = typed.trim();
|
|
159
|
+
if (key.length === 0) {
|
|
160
|
+
return yield* CredentialsUnavailable.make({
|
|
161
|
+
message: "No API key given: type it at the prompt, or pipe it in.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return Redacted.make(key);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/** `adhere login`: save the API key, so a run needs no TYPESAFE_API_KEY. */
|
|
168
|
+
export const loginCommand = Command.make("login", {}, () =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const credentials = yield* Credentials;
|
|
171
|
+
yield* credentials.save(yield* readApiKey());
|
|
172
|
+
yield* Console.log(`Saved the API key to ${yield* credentials.file}.`);
|
|
173
|
+
}),
|
|
174
|
+
).pipe(
|
|
175
|
+
Command.withDescription(
|
|
176
|
+
"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.",
|
|
177
|
+
),
|
|
178
|
+
Command.provide(CredentialsLive),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/** `adhere logout`: delete the saved API key. */
|
|
182
|
+
export const logoutCommand = Command.make("logout", {}, () =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const credentials = yield* Credentials;
|
|
185
|
+
const file = yield* credentials.file;
|
|
186
|
+
const removed = yield* credentials.remove;
|
|
187
|
+
yield* Console.log(
|
|
188
|
+
removed ? `Deleted the API key saved in ${file}.` : `No API key is saved in ${file}.`,
|
|
189
|
+
);
|
|
190
|
+
}),
|
|
191
|
+
).pipe(
|
|
192
|
+
Command.withDescription(
|
|
193
|
+
"Delete the API key adhere login saved. TYPESAFE_API_KEY, when set, still applies.",
|
|
194
|
+
),
|
|
195
|
+
Command.provide(CredentialsLive),
|
|
196
|
+
);
|
|
197
|
+
|
|
130
198
|
/** `adhere skill`: the agent skill for writing rules, as shipped in the binary. */
|
|
131
199
|
export const skillCommand = Command.make("skill", {}, () => Console.log(skill.trimEnd())).pipe(
|
|
132
200
|
Command.withDescription(
|
|
@@ -140,11 +208,19 @@ export const cli = Command.make("adhere").pipe(
|
|
|
140
208
|
Command.withExamples([
|
|
141
209
|
{ command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
|
|
142
210
|
{ command: "adhere validate", description: "Check the config and rules without calling Jev" },
|
|
211
|
+
{ command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
|
|
143
212
|
{ command: "adhere lint", description: "Audit the working directory" },
|
|
144
213
|
{
|
|
145
214
|
command: "adhere lint --preset effect",
|
|
146
215
|
description: "Audit against the built-in Effect rules",
|
|
147
216
|
},
|
|
148
217
|
]),
|
|
149
|
-
Command.withSubcommands([
|
|
218
|
+
Command.withSubcommands([
|
|
219
|
+
lintCommand,
|
|
220
|
+
validateCommand,
|
|
221
|
+
initCommand,
|
|
222
|
+
loginCommand,
|
|
223
|
+
logoutCommand,
|
|
224
|
+
skillCommand,
|
|
225
|
+
]),
|
|
150
226
|
);
|
package/src/contradictions.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import type { RuleEntry } from "#rules.ts";
|
|
2
|
+
import { Jev, type Pair } from "#services/Jev.ts";
|
|
3
|
+
import { Effect } from "effect";
|
|
2
4
|
|
|
3
5
|
export interface Contradiction {
|
|
4
6
|
readonly first: RuleEntry;
|
|
5
7
|
readonly second: RuleEntry;
|
|
6
|
-
|
|
8
|
+
/** Jev's probability that no code can follow both rules. */
|
|
9
|
+
readonly probability: number;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
|
-
type Polarity = "positive" | "negative";
|
|
10
|
-
|
|
11
|
-
const NEGATIVE = /\b(?:avoid|do not|don't|must not|never|no|should not)\b/gi;
|
|
12
|
-
const POSITIVE = /\b(?:always|must|prefer|require|requires|required|should|use)\b/gi;
|
|
13
|
-
const NEGATIVE_WORD = /\b(?:avoid|do not|don't|must not|never|no|should not)\b/i;
|
|
14
|
-
const POSITIVE_WORD = /\b(?:always|must|prefer|require|requires|required|should|use)\b/i;
|
|
15
|
-
const FILLER = /\b(?:a|an|the|for|to|with|by|in|of)\b/gi;
|
|
16
|
-
|
|
17
12
|
const normalized = (path: string): string => path.replaceAll("\\", "/");
|
|
18
13
|
|
|
19
14
|
const scopesOverlap = (a: string, b: string): boolean => {
|
|
@@ -22,58 +17,57 @@ const scopesOverlap = (a: string, b: string): boolean => {
|
|
|
22
17
|
return first === second || first.startsWith(`${second}/`) || second.startsWith(`${first}/`);
|
|
23
18
|
};
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.trim()
|
|
39
|
-
.replace(/\s+/g, " ");
|
|
20
|
+
/**
|
|
21
|
+
* Per rule, the rules that apply to some of the same files. A rule with the
|
|
22
|
+
* same id is left out: a nested rule that shares an id shadows the other on
|
|
23
|
+
* purpose.
|
|
24
|
+
*/
|
|
25
|
+
const partnersOf = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<ReadonlyArray<number>> =>
|
|
26
|
+
entries.map((first, index) =>
|
|
27
|
+
entries.flatMap((second, other) =>
|
|
28
|
+
other !== index && first.id !== second.id && scopesOverlap(first.scope, second.scope)
|
|
29
|
+
? [other]
|
|
30
|
+
: [],
|
|
31
|
+
),
|
|
32
|
+
);
|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
firstPolarity === undefined ||
|
|
48
|
-
secondPolarity === undefined ||
|
|
49
|
-
firstPolarity === secondPolarity
|
|
50
|
-
) {
|
|
51
|
-
return false;
|
|
34
|
+
/** Each pair Jev named once, lower index first. */
|
|
35
|
+
const distinctPairs = (named: ReadonlyArray<Pair>): ReadonlyArray<Pair> => {
|
|
36
|
+
const pairs = new Map<string, Pair>();
|
|
37
|
+
for (const [rule, partner] of named) {
|
|
38
|
+
const pair: Pair = rule < partner ? [rule, partner] : [partner, rule];
|
|
39
|
+
pairs.set(`${pair[0]}-${pair[1]}`, pair);
|
|
52
40
|
}
|
|
53
|
-
|
|
54
|
-
const secondTopic = topicOf(second.rule.description);
|
|
55
|
-
return firstTopic.length > 0 && firstTopic === secondTopic;
|
|
41
|
+
return [...pairs.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
56
42
|
};
|
|
57
43
|
|
|
58
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Pairs of rules that apply to the same files and that Jev judges no code can
|
|
46
|
+
* follow both of, above `threshold`. Jev first names, per rule, which rule
|
|
47
|
+
* sharing its files it conflicts with, then gives each named pair a
|
|
48
|
+
* probability. Nothing is sent when no two rules share files, so such a run
|
|
49
|
+
* needs no API key.
|
|
50
|
+
*/
|
|
51
|
+
export const findContradictions = Effect.fn("findContradictions")(function* (
|
|
59
52
|
entries: ReadonlyArray<RuleEntry>,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
53
|
+
threshold: number,
|
|
54
|
+
) {
|
|
55
|
+
const none: ReadonlyArray<Contradiction> = [];
|
|
56
|
+
const partners = partnersOf(entries);
|
|
57
|
+
if (partners.every((some) => some.length === 0)) return none;
|
|
58
|
+
const jev = yield* Jev;
|
|
59
|
+
const pairs = distinctPairs(yield* jev.conflicts(entries, partners));
|
|
60
|
+
if (pairs.length === 0) return none;
|
|
61
|
+
const probabilities = yield* jev.contradicts(entries, pairs);
|
|
62
|
+
return pairs.flatMap(([first, second], index): ReadonlyArray<Contradiction> => {
|
|
63
|
+
const one = entries[first];
|
|
64
|
+
const other = entries[second];
|
|
65
|
+
const probability = probabilities[index] ?? 0;
|
|
66
|
+
return one !== undefined && other !== undefined && probability > threshold
|
|
67
|
+
? [{ first: one, second: other, probability }]
|
|
68
|
+
: [];
|
|
69
|
+
});
|
|
70
|
+
});
|
|
77
71
|
|
|
78
72
|
const locationOf = (entry: RuleEntry): string => `${entry.id} (${entry.file ?? entry.scope})`;
|
|
79
73
|
|
|
@@ -87,7 +81,7 @@ export const formatContradictions = (contradictions: ReadonlyArray<Contradiction
|
|
|
87
81
|
} among configured rules:`,
|
|
88
82
|
"",
|
|
89
83
|
...contradictions.flatMap((contradiction, index) => [
|
|
90
|
-
`${index + 1}. ${contradiction.
|
|
84
|
+
`${index + 1}. No code can follow both (${contradiction.probability.toFixed(2)}):`,
|
|
91
85
|
` - ${locationOf(contradiction.first)}`,
|
|
92
86
|
` ${contradiction.first.rule.description}`,
|
|
93
87
|
` - ${locationOf(contradiction.second)}`,
|
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
|
+
};
|
|
@@ -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,15 +1,22 @@
|
|
|
1
1
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
2
|
+
import { Credentials } from "#services/Credentials.ts";
|
|
2
3
|
import {
|
|
3
4
|
blockBody,
|
|
5
|
+
type ComparedRule,
|
|
6
|
+
conflictBody,
|
|
7
|
+
contradictBody,
|
|
4
8
|
Jev,
|
|
5
9
|
JevUnavailable,
|
|
6
10
|
judgeBody,
|
|
7
11
|
type Lines,
|
|
8
12
|
locateBody,
|
|
13
|
+
namedPairs,
|
|
9
14
|
needsBlocks,
|
|
15
|
+
type Pair,
|
|
16
|
+
pairProbability,
|
|
10
17
|
type Rules,
|
|
11
18
|
} from "#services/Jev.ts";
|
|
12
|
-
import {
|
|
19
|
+
import { Effect, Layer, Record, Schedule, Schema } from "effect";
|
|
13
20
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
14
21
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
15
22
|
|
|
@@ -27,6 +34,7 @@ const refused = (message: string) => JevUnavailable.make({ message });
|
|
|
27
34
|
export const JevLive = Layer.effect(Jev)(
|
|
28
35
|
Effect.gen(function* () {
|
|
29
36
|
const config = yield* AdhereConfig;
|
|
37
|
+
const credentials = yield* Credentials;
|
|
30
38
|
const client = (yield* HttpClient.HttpClient).pipe(
|
|
31
39
|
HttpClient.filterStatusOk,
|
|
32
40
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
@@ -39,8 +47,8 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
39
47
|
const ask = <A>(body: unknown, Answers: Schema.Codec<A, unknown, never, never>) =>
|
|
40
48
|
Effect.gen(function* () {
|
|
41
49
|
// Read here, not in the layer: a run with nothing pending needs no key.
|
|
42
|
-
const apiKey = yield*
|
|
43
|
-
Effect.mapError((problem) => refused(
|
|
50
|
+
const apiKey = yield* credentials.apiKey.pipe(
|
|
51
|
+
Effect.mapError((problem) => refused(problem.message)),
|
|
44
52
|
);
|
|
45
53
|
const request = yield* HttpClientRequest.bodyJson(
|
|
46
54
|
HttpClientRequest.post(SYSTEM_ONE),
|
|
@@ -74,6 +82,28 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
74
82
|
return chosen(answers);
|
|
75
83
|
});
|
|
76
84
|
|
|
77
|
-
|
|
85
|
+
const conflicts = Effect.fn("Jev.conflicts")(function* (
|
|
86
|
+
rules: ReadonlyArray<ComparedRule>,
|
|
87
|
+
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
88
|
+
) {
|
|
89
|
+
const { answers } = yield* ask(conflictBody(config.model, rules, partners), ChoiceAnswers);
|
|
90
|
+
return namedPairs(answers, partners);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const contradicts = Effect.fn("Jev.contradicts")(function* (
|
|
94
|
+
rules: ReadonlyArray<ComparedRule>,
|
|
95
|
+
pairs: ReadonlyArray<Pair>,
|
|
96
|
+
) {
|
|
97
|
+
return yield* Effect.forEach(
|
|
98
|
+
pairs,
|
|
99
|
+
(pair) =>
|
|
100
|
+
Effect.map(ask(contradictBody(config.model, rules, pair), NoulAnswers), ({ answers }) =>
|
|
101
|
+
pairProbability(answers, pair),
|
|
102
|
+
),
|
|
103
|
+
{ concurrency: 8 },
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return Jev.of({ judge, locate, conflicts, contradicts });
|
|
78
108
|
}),
|
|
79
109
|
);
|
package/src/services/Jev.ts
CHANGED
|
@@ -19,9 +19,35 @@ export class Jev extends Context.Service<
|
|
|
19
19
|
lines: Lines,
|
|
20
20
|
rules: Rules,
|
|
21
21
|
) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
|
|
22
|
+
/**
|
|
23
|
+
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
24
|
+
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
25
|
+
* left out.
|
|
26
|
+
*/
|
|
27
|
+
readonly conflicts: (
|
|
28
|
+
rules: ReadonlyArray<ComparedRule>,
|
|
29
|
+
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
30
|
+
) => Effect.Effect<ReadonlyArray<Pair>, JevUnavailable>;
|
|
31
|
+
/**
|
|
32
|
+
* Per pair, in order, Jev's probability that no code can follow both rules,
|
|
33
|
+
* each pair asked on its own.
|
|
34
|
+
*/
|
|
35
|
+
readonly contradicts: (
|
|
36
|
+
rules: ReadonlyArray<ComparedRule>,
|
|
37
|
+
pairs: ReadonlyArray<Pair>,
|
|
38
|
+
) => Effect.Effect<ReadonlyArray<number>, JevUnavailable>;
|
|
22
39
|
}
|
|
23
40
|
>()("@drkmttr/adhere/services/Jev") {}
|
|
24
41
|
|
|
42
|
+
/** A rule compared with other rules rather than with a file. */
|
|
43
|
+
export interface ComparedRule {
|
|
44
|
+
readonly id: RuleId;
|
|
45
|
+
readonly rule: Rule;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Two rules, as indexes into the rules being compared. */
|
|
49
|
+
export type Pair = readonly [number, number];
|
|
50
|
+
|
|
25
51
|
/** A `choice` question accepts at most this many criteria. */
|
|
26
52
|
const CHOICE_LIMIT = 255;
|
|
27
53
|
const LINES_PER_BLOCK = 20;
|
|
@@ -34,15 +60,33 @@ const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
|
34
60
|
export const needsBlocks = (lines: Lines): boolean =>
|
|
35
61
|
lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
|
|
36
62
|
|
|
63
|
+
/** A rule's text as Jev reads it. Code a rule lacks is left out, not sent empty. */
|
|
64
|
+
const ruleState = ({ description, reference, avoid }: Rule) => ({
|
|
65
|
+
description,
|
|
66
|
+
...(reference === undefined ? {} : { reference }),
|
|
67
|
+
...(avoid === undefined ? {} : { avoid }),
|
|
68
|
+
});
|
|
69
|
+
|
|
37
70
|
const stateOf = (lines: Lines, rules: Rules) => ({
|
|
38
71
|
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
39
|
-
rules: Record.map(rules, (
|
|
40
|
-
description,
|
|
41
|
-
...(reference === undefined ? {} : { reference }),
|
|
42
|
-
...(avoid === undefined ? {} : { avoid }),
|
|
43
|
-
})),
|
|
72
|
+
rules: Record.map(rules, (rule) => ruleState(rule)),
|
|
44
73
|
});
|
|
45
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The compared rules a question mentions, keyed by index: ids alone would
|
|
77
|
+
* collide, since a nested rule can share its id with the rule it shadows.
|
|
78
|
+
*/
|
|
79
|
+
const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<number>) => {
|
|
80
|
+
const state: Record<string, { readonly id: RuleId } & ReturnType<typeof ruleState>> = {};
|
|
81
|
+
for (const index of [...new Set(mentioned)].sort((a, b) => a - b)) {
|
|
82
|
+
const compared = rules[index];
|
|
83
|
+
if (compared !== undefined) {
|
|
84
|
+
state[String(index)] = { id: compared.id, ...ruleState(compared.rule) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { rules: state };
|
|
88
|
+
};
|
|
89
|
+
|
|
46
90
|
const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
|
|
47
91
|
`state.rules["${id}"].${key}`;
|
|
48
92
|
|
|
@@ -123,3 +167,83 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
123
167
|
})),
|
|
124
168
|
};
|
|
125
169
|
};
|
|
170
|
+
|
|
171
|
+
/** A choice keeps one criterion for "none", which leaves this many for partners. */
|
|
172
|
+
const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
|
|
173
|
+
|
|
174
|
+
interface ChoiceQuestion {
|
|
175
|
+
readonly type: "choice";
|
|
176
|
+
readonly instructions: string;
|
|
177
|
+
readonly criteria: Record<string, string>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Per rule, one `choice` among the rules that apply to the same files: which
|
|
182
|
+
* of them cannot be followed in the same code, or none. The questions grow
|
|
183
|
+
* with the rules, not with every pair of them. A rule with more partners than
|
|
184
|
+
* a choice holds gets one question per chunk, keyed `rule:chunk`.
|
|
185
|
+
*/
|
|
186
|
+
export const conflictBody = (
|
|
187
|
+
model: string,
|
|
188
|
+
rules: ReadonlyArray<ComparedRule>,
|
|
189
|
+
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
190
|
+
) => {
|
|
191
|
+
const questions: Record<string, ChoiceQuestion> = {};
|
|
192
|
+
const mentioned: Array<number> = [];
|
|
193
|
+
partners.forEach((some, index) => {
|
|
194
|
+
for (let chunk = 0; chunk * PARTNERS_PER_QUESTION < some.length; chunk++) {
|
|
195
|
+
const from = chunk * PARTNERS_PER_QUESTION;
|
|
196
|
+
const offered = some.slice(from, from + PARTNERS_PER_QUESTION);
|
|
197
|
+
const criteria: Record<string, string> = { none: "None of them" };
|
|
198
|
+
for (const partner of offered) {
|
|
199
|
+
criteria[String(partner)] = rules[partner]?.rule.description ?? "";
|
|
200
|
+
}
|
|
201
|
+
mentioned.push(index, ...offered);
|
|
202
|
+
questions[`${index}:${chunk}`] = {
|
|
203
|
+
type: "choice",
|
|
204
|
+
instructions: `Each choice other than none is the rule in state.rules under its key. Which of them demands the opposite of what state.rules["${index}"] demands of the same code, so that following one breaks the other? Choose none if code can follow each of them alongside it.`,
|
|
205
|
+
criteria,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return { model, state: comparedState(rules, mentioned), questions };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const pairKey = ([first, second]: Pair): string => `${first}-${second}`;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A `noul` for one pair Jev named: do the two rules demand opposite things of
|
|
216
|
+
* the same code? One pair per request, with only its two rules in the state.
|
|
217
|
+
* Other rules beside them dilute the judgment: over the effect preset, a real
|
|
218
|
+
* contradiction scored 0.89 alone and 0.59 among the other named pairs.
|
|
219
|
+
*/
|
|
220
|
+
export const contradictBody = (model: string, rules: ReadonlyArray<ComparedRule>, pair: Pair) => ({
|
|
221
|
+
model,
|
|
222
|
+
state: comparedState(rules, pair),
|
|
223
|
+
questions: {
|
|
224
|
+
[pairKey(pair)]: {
|
|
225
|
+
type: "noul" as const,
|
|
226
|
+
instructions: `Do state.rules["${pair[0]}"] and state.rules["${pair[1]}"] demand opposite things of the same code, so that following one breaks the other? Answer no if code can follow both, or if they are about different things.`,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The pairs a conflict answer names: per question, the partner Jev chose, when
|
|
233
|
+
* it was one the question offered. "none", or a stray answer, names no pair.
|
|
234
|
+
*/
|
|
235
|
+
export const namedPairs = (
|
|
236
|
+
answers: Readonly<Record<string, { readonly choice: string }>>,
|
|
237
|
+
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
238
|
+
): ReadonlyArray<Pair> =>
|
|
239
|
+
Object.entries(answers).flatMap(([key, { choice }]): ReadonlyArray<Pair> => {
|
|
240
|
+
const rule = Number(key.split(":")[0]);
|
|
241
|
+
const partner = Number(choice);
|
|
242
|
+
return partners[rule]?.includes(partner) === true ? [[rule, partner]] : [];
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** The probability a contradiction answer gives its pair; 0 when it gives none. */
|
|
246
|
+
export const pairProbability = (
|
|
247
|
+
answers: Readonly<Record<string, { readonly noul: number }>>,
|
|
248
|
+
pair: Pair,
|
|
249
|
+
): number => answers[pairKey(pair)]?.noul ?? 0;
|
package/src/workflows/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
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 = [
|
|
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
|
-
|
|
79
|
-
`Found ${counted(result.findings.length, "error", "errors")}
|
|
80
|
-
`${
|
|
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));
|