@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.
Files changed (30) hide show
  1. package/README.md +55 -21
  2. package/package.json +6 -6
  3. package/presets/effect/basics/fn-for-named-effects.md +1 -1
  4. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  5. package/presets/effect/basics/instrument-with-pipe.md +1 -1
  6. package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
  7. package/presets/effect/config/secrets-are-redacted.md +1 -1
  8. package/presets/effect/config/validate-with-schema.md +1 -1
  9. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  10. package/presets/effect/data/decode-json-with-schema.md +1 -1
  11. package/presets/effect/data/records-are-schema-classes.md +1 -1
  12. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  13. package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
  14. package/presets/effect/errors/defects-for-bugs.md +1 -1
  15. package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
  16. package/presets/effect/errors/wrap-external-errors.md +1 -1
  17. package/presets/effect/platform/os-access-through-platform-services.md +1 -1
  18. package/presets/effect/services/fresh-layer-per-test.md +1 -1
  19. package/presets/effect/services/memoize-parameterized-layers.md +1 -1
  20. package/presets/effect/services/methods-have-no-requirements.md +1 -1
  21. package/presets/effect/services/provide-at-entry.md +1 -1
  22. package/presets/effect/services/test-layers-are-in-memory.md +1 -1
  23. package/presets/effect/testing/test-clock-for-time.md +1 -1
  24. package/skills/adhere/SKILL.md +1 -1
  25. package/src/cli.ts +86 -8
  26. package/src/contradictions.ts +51 -57
  27. package/src/services/Jev.http.ts +80 -11
  28. package/src/services/Jev.ts +244 -36
  29. package/src/workflows/audit.ts +195 -79
  30. 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 validate # load the config and rules without Jev, check for contradictions
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, without calling Jev, so a file that fails to decode refuses here too, and
103
- it prints how many rules it loaded. `--preset` adds a built-in rule set, as for
104
- `lint`. It then checks the rules for local textual contradictions: it exits 1
105
- and prints the conflicting rule files when overlapping rule scopes contain
106
- opposite textual directives for the same topic, such as "Use service classes
107
- for IO" and "Do not use service classes for IO". Same-id nested rules are
108
- treated as intentional shadowing rather than contradictions.
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` uses the saved key, but
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, is a branded schema.",
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, with the file's numbered lines and every rule as
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.
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
- Files longer than 5100 lines are skipped and counted in the summary.
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 rule's text and model.
269
- A changed file re-judges every rule for that file. An edited rule re-judges
270
- only that rule. A lowered threshold locates cached judgments that are newly
271
- above it without judging again. Entries depend on content, not on the
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.4.1",
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.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"
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: A named function that returns an Effect is defined with Effect.fn so the call site is traced.
2
+ description: A named function that returns an Effect should be defined with Effect.fn so the call site is traced.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Sequential effectful steps are written with Effect.gen and yield*, not nested flatMap or callback chains.
2
+ description: Sequential effectful steps should be written with Effect.gen and yield*, not nested flatMap or callback chains.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Timeouts, retries, logging, and spans are attached with .pipe, not written into the body of the effect.
2
+ description: Timeouts, retries, logging, and spans should be attached with .pipe, not written into the body of the effect.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Business logic depends on a config service. Config primitives are read only inside that service's layer.
2
+ description: Business logic depends on a config service. Config primitives should be read only inside that service's layer.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A token, password, or key is read with Config.redacted.
2
+ description: A token, password, or key should be read with Config.redacted.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A config value with constraints is read with Config.schema, not mapped and checked by hand.
2
+ description: A config value with constraints should be read with Config.schema, not mapped and checked by hand.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A primitive with semantic meaning, such as an id, email, URL, port, or count, is a branded schema.
2
+ description: A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: JSON crossing a boundary is decoded with Schema.fromJsonString, not JSON.parse followed by a cast.
2
+ description: JSON crossing a boundary should be decoded with Schema.fromJsonString, not JSON.parse followed by a cast.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Domain records are defined with Schema.Class, not with a plain interface or type alias.
2
+ description: Domain records should be defined with Schema.Class, not with a plain interface or type alias.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Structured variants are Schema.TaggedClass in a Schema.Union and are matched with Match.tag and Match.exhaustive.
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: Defects are caught only at a system boundary for logging or shutdown, never in business logic.
2
+ description: Defects should be caught only at a system boundary for logging or shutdown, never in business logic.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A typed error is for a failure the caller can handle. A bug or invariant violation is a defect.
2
+ description: A typed error should be for a failure the caller can handle. A bug or invariant violation should be a defect.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A domain failure is a Schema.TaggedError with its own tag.
2
+ description: A domain failure should be a Schema.TaggedError with its own tag.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: An error from an external library is wrapped in a tagged error with a Schema.Defect field, not passed through raw.
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 are provided once at the entry point with BunServices.layer.
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 is used only to share an expensive resource across a suite.
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 is stored in a module constant before it is used in more than one place.
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 are acquired inside the layer, not declared on the method.
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 are provided once at the program entry. A module that is not an entry point does not call Effect.provide.
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 implementation of a service is built with Layer.sync or Layer.succeed over in-memory state.
2
+ description: A test implementation of a service should be built with Layer.sync or Layer.succeed over in-memory state.
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 are used only when real time is required.
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
@@ -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, checks contradictions
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 { render, runAudit } from "#workflows/audit.ts";
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, without Jev. A config, rule file,
99
- * or preset that does not decode refuses here the way it would refuse `lint`.
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, without calling Jev, and check the rules for contradictions.",
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) => AdhereConfigLive({ presets: chosenPresets(input.preset) })),
195
+ Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
118
196
  );
119
197
 
120
198
  /** `adhere init`: the scaffold. */
@@ -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
- readonly reason: string;
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
- const polarityOf = (text: string): Polarity | undefined => {
26
- if (NEGATIVE_WORD.test(text)) return "negative";
27
- if (POSITIVE_WORD.test(text)) return "positive";
28
- return undefined;
29
- };
30
-
31
- const topicOf = (text: string): string =>
32
- text
33
- .toLowerCase()
34
- .replaceAll(NEGATIVE, " ")
35
- .replaceAll(POSITIVE, " ")
36
- .replaceAll(FILLER, " ")
37
- .replace(/[^a-z0-9]+/g, " ")
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
- const contradicts = (first: RuleEntry, second: RuleEntry): boolean => {
42
- if (first.id === second.id) return false;
43
- if (!scopesOverlap(first.scope, second.scope)) return false;
44
- const firstPolarity = polarityOf(first.rule.description);
45
- const secondPolarity = polarityOf(second.rule.description);
46
- if (
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
- const firstTopic = topicOf(first.rule.description);
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
- export const findContradictions = (
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
- ): ReadonlyArray<Contradiction> => {
61
- const contradictions: Array<Contradiction> = [];
62
- for (let i = 0; i < entries.length; i++) {
63
- const first = entries[i];
64
- if (first === undefined) continue;
65
- for (let j = i + 1; j < entries.length; j++) {
66
- const second = entries[j];
67
- if (second === undefined || !contradicts(first, second)) continue;
68
- contradictions.push({
69
- first,
70
- second,
71
- reason: "overlapping scopes contain opposite textual directives for the same topic",
72
- });
73
- }
74
- }
75
- return contradictions;
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.reason}`,
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)}`,