@drkmttr/adhere 0.4.1 → 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 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,7 @@ 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 validate # load the config and rules, ask Jev whether any contradict
72
72
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
73
73
  adhere login # save a TypeSafe AI API key for later runs
74
74
  adhere logout # delete the saved key
@@ -99,20 +99,24 @@ kept, and no second one is added, even with `--force`.
99
99
  ### Validate
100
100
 
101
101
  `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.
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.
109
113
 
110
114
  ### Login
111
115
 
112
116
  `adhere login` prompts for a TypeSafe AI API key, masking what you type, and
113
117
  saves it to `~/.config/adhere/credentials.json`, or under `$XDG_CONFIG_HOME`
114
118
  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
119
+ prompt: `adhere login < key.txt`. `lint` and `validate` use the saved key, but
116
120
  `TYPESAFE_API_KEY`, when set, takes precedence, so CI can pass a key without a
117
121
  login. `adhere logout` deletes the saved key.
118
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.4.1",
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.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.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
  }
@@ -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
@@ -94,16 +94,24 @@ export const lintCommand = Command.make("lint", { preset, threshold }, () =>
94
94
  ),
95
95
  );
96
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
+
97
104
  /**
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`.
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`.
100
108
  */
101
109
  export const validateCommand = Command.make("validate", { preset }, () =>
102
110
  Effect.gen(function* () {
103
111
  const config = yield* AdhereConfig;
104
112
  const path = yield* Path.Path;
105
113
  const entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
106
- const contradictions = findContradictions(entries);
114
+ const contradictions = yield* findContradictions(entries, config.threshold);
107
115
  const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
108
116
  yield* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
109
117
  if (contradictions.length > 0) {
@@ -112,9 +120,9 @@ export const validateCommand = Command.make("validate", { preset }, () =>
112
120
  }),
113
121
  ).pipe(
114
122
  Command.withDescription(
115
- "Load the config, rule files, and presets the way lint does, without calling Jev, and check the rules for contradictions.",
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.",
116
124
  ),
117
- Command.provide((input) => AdhereConfigLive({ presets: chosenPresets(input.preset) })),
125
+ Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
118
126
  );
119
127
 
120
128
  /** `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)}`,
@@ -2,12 +2,18 @@ import { AdhereConfig } from "#services/AdhereConfig.ts";
2
2
  import { Credentials } from "#services/Credentials.ts";
3
3
  import {
4
4
  blockBody,
5
+ type ComparedRule,
6
+ conflictBody,
7
+ contradictBody,
5
8
  Jev,
6
9
  JevUnavailable,
7
10
  judgeBody,
8
11
  type Lines,
9
12
  locateBody,
13
+ namedPairs,
10
14
  needsBlocks,
15
+ type Pair,
16
+ pairProbability,
11
17
  type Rules,
12
18
  } from "#services/Jev.ts";
13
19
  import { Effect, Layer, Record, Schedule, Schema } from "effect";
@@ -76,6 +82,28 @@ export const JevLive = Layer.effect(Jev)(
76
82
  return chosen(answers);
77
83
  });
78
84
 
79
- return Jev.of({ judge, locate });
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 });
80
108
  }),
81
109
  );
@@ -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, ({ description, reference, avoid }) => ({
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;