@drkmttr/adhere 0.4.1 → 0.6.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 +55 -21
- package/package.json +6 -6
- package/presets/effect/basics/fn-for-named-effects.md +1 -1
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/basics/instrument-with-pipe.md +1 -1
- package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
- package/presets/effect/config/secrets-are-redacted.md +1 -1
- package/presets/effect/config/validate-with-schema.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/decode-json-with-schema.md +1 -1
- package/presets/effect/data/records-are-schema-classes.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
- package/presets/effect/errors/defects-for-bugs.md +1 -1
- package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
- package/presets/effect/errors/wrap-external-errors.md +1 -1
- package/presets/effect/platform/os-access-through-platform-services.md +1 -1
- package/presets/effect/services/fresh-layer-per-test.md +1 -1
- package/presets/effect/services/memoize-parameterized-layers.md +1 -1
- package/presets/effect/services/methods-have-no-requirements.md +1 -1
- package/presets/effect/services/provide-at-entry.md +1 -1
- package/presets/effect/services/test-layers-are-in-memory.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +1 -1
- package/src/cli.ts +86 -8
- package/src/contradictions.ts +51 -57
- package/src/services/Jev.http.ts +80 -11
- package/src/services/Jev.ts +244 -36
- package/src/workflows/audit.ts +195 -79
- package/src/workflows/format.ts +42 -1
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
|
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
32
|
adhere init # .adhere/config.ts and two example rules
|
|
33
|
-
adhere validate # check the config and rules, without Jev
|
|
34
33
|
echo ".adhere/cache/" >> .gitignore
|
|
35
34
|
adhere login # save your TypeSafe AI API key, once
|
|
35
|
+
adhere validate # check the rules, and ask Jev whether any contradict
|
|
36
36
|
adhere lint # audit the working directory
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -68,7 +68,8 @@ refusal prints its reason.
|
|
|
68
68
|
adhere lint # audit the working directory
|
|
69
69
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
70
70
|
adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
|
|
71
|
-
adhere
|
|
71
|
+
adhere lint --yes # send the requests without asking first
|
|
72
|
+
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
72
73
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
73
74
|
adhere login # save a TypeSafe AI API key for later runs
|
|
74
75
|
adhere logout # delete the saved key
|
|
@@ -79,6 +80,24 @@ Bare `adhere` prints the help, which lists the commands, and
|
|
|
79
80
|
`adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
|
|
80
81
|
prints a completion script.
|
|
81
82
|
|
|
83
|
+
### Before and during a run
|
|
84
|
+
|
|
85
|
+
Before it sends anything, `lint` says on stderr what it found and what judging
|
|
86
|
+
takes:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
200 files and 14 rules: 2800 checks, 1400 cached.
|
|
90
|
+
Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
|
|
91
|
+
? Send 200 requests to Jev? › (Y/n)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
It asks only with a terminal on stdin and stdout, and never when the cache
|
|
95
|
+
answers every check; `--yes` sends without asking. While it runs, a counter on
|
|
96
|
+
stderr shows the files done, the requests sent, and the findings so far, and
|
|
97
|
+
the report goes to stdout. A request Jev refuses stops the run with the file,
|
|
98
|
+
the HTTP status, and Jev's reason. Files finished before it are cached, so
|
|
99
|
+
running again continues from there.
|
|
100
|
+
|
|
82
101
|
### What gets read
|
|
83
102
|
|
|
84
103
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
|
@@ -99,20 +118,24 @@ kept, and no second one is added, even with `--force`.
|
|
|
99
118
|
### Validate
|
|
100
119
|
|
|
101
120
|
`adhere validate` loads the config, rule files, and presets the way `lint`
|
|
102
|
-
does,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
does, so a file that fails to decode refuses here too, and it prints how many
|
|
122
|
+
rules it loaded. `--preset` adds a built-in rule set, as for `lint`. It then
|
|
123
|
+
asks Jev whether any two rules that apply to the same files contradict, so that
|
|
124
|
+
no code can follow both: one request names, per rule, the rule it conflicts
|
|
125
|
+
with, if any, and each named pair then gets a probability from a request of
|
|
126
|
+
its own, holding only those two rules, since other rules beside them dilute
|
|
127
|
+
the judgment. A pair above
|
|
128
|
+
the threshold is printed with both rule files, and the exit code is 1. Two
|
|
129
|
+
rules with the same id are not compared: a nested rule that shares an id
|
|
130
|
+
shadows the other on purpose. When no two rules share files nothing is sent;
|
|
131
|
+
otherwise `validate` needs the API key, as `lint` does.
|
|
109
132
|
|
|
110
133
|
### Login
|
|
111
134
|
|
|
112
135
|
`adhere login` prompts for a TypeSafe AI API key, masking what you type, and
|
|
113
136
|
saves it to `~/.config/adhere/credentials.json`, or under `$XDG_CONFIG_HOME`
|
|
114
137
|
when that is set, readable only by you. Piped input is read instead of a
|
|
115
|
-
prompt: `adhere login < key.txt`. `lint`
|
|
138
|
+
prompt: `adhere login < key.txt`. `lint` and `validate` use the saved key, but
|
|
116
139
|
`TYPESAFE_API_KEY`, when set, takes precedence, so CI can pass a key without a
|
|
117
140
|
login. `adhere logout` deletes the saved key.
|
|
118
141
|
|
|
@@ -137,7 +160,7 @@ export default {
|
|
|
137
160
|
rules: {
|
|
138
161
|
"data/brand-meaningful-primitives": {
|
|
139
162
|
description:
|
|
140
|
-
"A primitive with semantic meaning, such as an id, email, URL, port, or count,
|
|
163
|
+
"A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
|
|
141
164
|
reference: `
|
|
142
165
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
143
166
|
type UserId = typeof UserId.Type
|
|
@@ -250,25 +273,36 @@ beats all of the above for that rule.
|
|
|
250
273
|
|
|
251
274
|
## How a file is judged
|
|
252
275
|
|
|
253
|
-
1. One Jev request per file
|
|
254
|
-
|
|
255
|
-
the
|
|
256
|
-
|
|
257
|
-
of the
|
|
276
|
+
1. One Jev request per file. The state is the file's numbered lines and
|
|
277
|
+
nothing else. Each rule is one `noul` (yes/no probability) question that
|
|
278
|
+
carries the rule: whether the file breaks the rule's description, with
|
|
279
|
+
criteria for each answer. Yes means part of the file breaks the rule, and
|
|
280
|
+
the code to avoid is an example of it. No means the file follows the rule
|
|
281
|
+
or has nothing the rule covers, and the reference is an example of it.
|
|
282
|
+
Since no rule sits in the shared state, a rule's probability depends only
|
|
283
|
+
on the file and that rule, not on which other rules share the request.
|
|
284
|
+
Every question shares the state cost of the request.
|
|
258
285
|
2. A second request only when at least one rule's probability is above its
|
|
259
286
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
260
287
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
261
288
|
lines is located in two steps (a block of 20 lines, then a line inside it).
|
|
262
|
-
|
|
289
|
+
3. Jev reads at most 32k tokens of state and one question together, and 64k
|
|
290
|
+
tokens in a request. adhere estimates tokens from the JSON it sends, at
|
|
291
|
+
about three bytes a token. When a file's questions would not fit in one
|
|
292
|
+
request, they are split across several. A file whose code and longest
|
|
293
|
+
question would not fit together, or that has more than 5100 lines, is
|
|
294
|
+
skipped and counted in the summary, and the rest of the run goes on.
|
|
263
295
|
|
|
264
296
|
## Cache
|
|
265
297
|
|
|
266
298
|
Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
|
|
267
299
|
per file. An entry stores the file's content hash and, per rule, the
|
|
268
|
-
probability, the located line, and a fingerprint of the
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
300
|
+
probability, the located line, and a fingerprint of the model and the
|
|
301
|
+
question asked for the rule, which carries the rule's text. A changed file
|
|
302
|
+
re-judges every rule for that file. An edited rule re-judges only that rule,
|
|
303
|
+
and an adhere that asks its questions differently re-judges every rule once.
|
|
304
|
+
A lowered threshold locates cached judgments that are newly above it without
|
|
305
|
+
judging again. Entries depend on content, not on the
|
|
272
306
|
machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
|
|
273
307
|
|
|
274
308
|
The API key is read only when a request is about to be sent. A run where
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.6.0",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.6.0",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.6.0",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.6.0",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.6.0"
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Structured variants
|
|
2
|
+
description: Structured variants should be Schema.TaggedClass in a Schema.Union and matched with Match.tag and Match.exhaustive.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: An error from an external library
|
|
2
|
+
description: An error from an external library should be wrapped in a tagged error with a Schema.Defect field, not passed through raw.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations
|
|
2
|
+
description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations should be provided once at the entry point with BunServices.layer.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Each it.effect provides its own layer. it.layer
|
|
2
|
+
description: Each it.effect provides its own layer. it.layer should be used only to share an expensive resource across a suite.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: The result of a parameterized layer constructor
|
|
2
|
+
description: The result of a parameterized layer constructor should be stored in a module constant before it is used in more than one place.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Service method signatures have no requirements. Dependencies
|
|
2
|
+
description: Service method signatures have no requirements. Dependencies should be acquired inside the layer, not declared on the method.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Layers
|
|
2
|
+
description: Layers should be provided once at the program entry. A module that is not an entry point does not call Effect.provide.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A test that depends on time uses TestClock. Real sleeps and it.live
|
|
2
|
+
description: A test that depends on time uses TestClock. Real sleeps and it.live should be used only when real time is required.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -103,7 +103,7 @@ precedence; in this org it comes from SOPS via `just shell`.
|
|
|
103
103
|
## 5. Run and calibrate
|
|
104
104
|
|
|
105
105
|
```sh
|
|
106
|
-
adhere validate # decodes the config and rules,
|
|
106
|
+
adhere validate # decodes the config and rules, asks Jev about contradictions
|
|
107
107
|
adhere lint # or: adhere lint --preset effect
|
|
108
108
|
adhere lint --threshold 0.9 # re-reads the cache, sends nothing
|
|
109
109
|
```
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,20 @@ import { AuditCacheLive } from "#services/AuditCache.ts";
|
|
|
3
3
|
import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
|
|
4
4
|
import { JevLive } from "#services/Jev.http.ts";
|
|
5
5
|
import { SourceWalkerLive } from "#services/SourceWalker.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type AuditPlan,
|
|
8
|
+
executeAudit,
|
|
9
|
+
type FileDone,
|
|
10
|
+
planAudit,
|
|
11
|
+
render,
|
|
12
|
+
} from "#workflows/audit.ts";
|
|
13
|
+
import {
|
|
14
|
+
describePlan,
|
|
15
|
+
type Progress,
|
|
16
|
+
progressAfter,
|
|
17
|
+
progressLine,
|
|
18
|
+
sendQuestion,
|
|
19
|
+
} from "#workflows/format.ts";
|
|
7
20
|
import type { Overrides } from "#config.ts";
|
|
8
21
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
9
22
|
import { initProject } from "#init.ts";
|
|
@@ -12,6 +25,7 @@ import { globalRuleSet } from "#rules.ts";
|
|
|
12
25
|
import {
|
|
13
26
|
Console,
|
|
14
27
|
Effect,
|
|
28
|
+
Exit,
|
|
15
29
|
Layer,
|
|
16
30
|
Option,
|
|
17
31
|
Path,
|
|
@@ -54,6 +68,44 @@ const threshold = Flag.float("threshold").pipe(
|
|
|
54
68
|
),
|
|
55
69
|
);
|
|
56
70
|
|
|
71
|
+
const yes = Flag.boolean("yes").pipe(
|
|
72
|
+
Flag.withAlias("y"),
|
|
73
|
+
Flag.withDefault(false),
|
|
74
|
+
Flag.withDescription(
|
|
75
|
+
"Send the requests without asking first. lint only asks with a terminal on stdin and stdout.",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
class Cancelled extends Schema.TaggedError<Cancelled>()("Cancelled", {}) {
|
|
80
|
+
readonly [Runtime.errorReported] = false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Text on stderr, beside the report on stdout. */
|
|
84
|
+
const toStderr = (text: string) =>
|
|
85
|
+
Effect.sync(() => {
|
|
86
|
+
process.stderr.write(text);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A counter on stderr, redrawn in place on a terminal as files finish. On
|
|
91
|
+
* anything else it stays quiet, so a log gets the plan and the report rather
|
|
92
|
+
* than a line per file. A failed run keeps its last count on screen.
|
|
93
|
+
*/
|
|
94
|
+
const counterFor = (plan: AuditPlan) => {
|
|
95
|
+
const live = process.stderr.isTTY === true;
|
|
96
|
+
let progress: Progress = { files: 0, requests: 0, findings: 0 };
|
|
97
|
+
const draw = () => (live ? toStderr(`\r\u001b[2K${progressLine(progress, plan)}`) : Effect.void);
|
|
98
|
+
return {
|
|
99
|
+
start: Effect.suspend(draw),
|
|
100
|
+
update: (done: FileDone) =>
|
|
101
|
+
Effect.suspend(() => {
|
|
102
|
+
progress = progressAfter(progress, done);
|
|
103
|
+
return draw();
|
|
104
|
+
}),
|
|
105
|
+
finish: (failed: boolean) => (live ? toStderr(failed ? "\n" : "\r\u001b[2K") : Effect.void),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
57
109
|
const force = Flag.boolean("force").pipe(
|
|
58
110
|
Flag.withDefault(false),
|
|
59
111
|
Flag.withDescription("Overwrite existing scaffold files."),
|
|
@@ -70,10 +122,28 @@ export const auditLayer = (overrides: Overrides) =>
|
|
|
70
122
|
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
71
123
|
|
|
72
124
|
/** `adhere lint`: the audit. */
|
|
73
|
-
export const lintCommand = Command.make("lint", { preset, threshold }, () =>
|
|
125
|
+
export const lintCommand = Command.make("lint", { preset, threshold, yes }, (input) =>
|
|
74
126
|
Effect.gen(function* () {
|
|
75
|
-
const result = yield* runAudit;
|
|
76
127
|
const stdio = yield* Stdio.Stdio;
|
|
128
|
+
const plan = yield* planAudit;
|
|
129
|
+
yield* toStderr(`${describePlan(plan).join("\n")}\n`);
|
|
130
|
+
// A prompt draws on stdout, so it needs a terminal at both ends: none when
|
|
131
|
+
// the report is redirected, or in CI.
|
|
132
|
+
const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
|
|
133
|
+
if (plan.requests > 0 && interactive && !input.yes) {
|
|
134
|
+
const send = yield* Prompt.run(
|
|
135
|
+
Prompt.confirm({ message: sendQuestion(plan), initial: true }),
|
|
136
|
+
);
|
|
137
|
+
if (!send) {
|
|
138
|
+
yield* toStderr("Nothing sent.\n");
|
|
139
|
+
return yield* Cancelled.make({});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const counter = counterFor(plan);
|
|
143
|
+
yield* counter.start;
|
|
144
|
+
const result = yield* executeAudit(plan, counter.update).pipe(
|
|
145
|
+
Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
|
|
146
|
+
);
|
|
77
147
|
const color = yield* stdio.stdoutIsTerminal;
|
|
78
148
|
const path = yield* Path.Path;
|
|
79
149
|
// One write: separate Console.log calls have interleaved out of order here.
|
|
@@ -94,16 +164,24 @@ export const lintCommand = Command.make("lint", { preset, threshold }, () =>
|
|
|
94
164
|
),
|
|
95
165
|
);
|
|
96
166
|
|
|
167
|
+
/** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
|
|
168
|
+
export const validateLayer = (overrides: Overrides) =>
|
|
169
|
+
JevLive.pipe(
|
|
170
|
+
Layer.provide([FetchHttpClient.layer, CredentialsLive]),
|
|
171
|
+
Layer.provideMerge(AdhereConfigLive(overrides)),
|
|
172
|
+
);
|
|
173
|
+
|
|
97
174
|
/**
|
|
98
|
-
* `adhere validate`: everything `lint` loads,
|
|
99
|
-
*
|
|
175
|
+
* `adhere validate`: everything `lint` loads, then Jev on whether any two rules
|
|
176
|
+
* that apply to the same files contradict. A config, rule file, or preset that
|
|
177
|
+
* does not decode refuses here the way it would refuse `lint`.
|
|
100
178
|
*/
|
|
101
179
|
export const validateCommand = Command.make("validate", { preset }, () =>
|
|
102
180
|
Effect.gen(function* () {
|
|
103
181
|
const config = yield* AdhereConfig;
|
|
104
182
|
const path = yield* Path.Path;
|
|
105
183
|
const entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
|
|
106
|
-
const contradictions = findContradictions(entries);
|
|
184
|
+
const contradictions = yield* findContradictions(entries, config.threshold);
|
|
107
185
|
const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
|
|
108
186
|
yield* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
|
|
109
187
|
if (contradictions.length > 0) {
|
|
@@ -112,9 +190,9 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
112
190
|
}),
|
|
113
191
|
).pipe(
|
|
114
192
|
Command.withDescription(
|
|
115
|
-
"Load the config, rule files, and presets the way lint does,
|
|
193
|
+
"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.",
|
|
116
194
|
),
|
|
117
|
-
Command.provide((input) =>
|
|
195
|
+
Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
|
|
118
196
|
);
|
|
119
197
|
|
|
120
198
|
/** `adhere init`: the scaffold. */
|
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)}`,
|