@drkmttr/adhere 0.3.1 → 0.4.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
@@ -1,9 +1,9 @@
1
1
  # adhere
2
2
 
3
- A linter for rules a normal linter cannot check. A rule is a piece of correct
4
- reference code plus a one-sentence description. For each source file, adhere
5
- asks [Jev](https://typesafe.ai) (TypeSafe AI's System One model) whether the
6
- file diverges from each rule's reference, gets a calibrated probability per
3
+ A linter for rules a normal linter cannot check. A rule is a one-sentence
4
+ description plus correct reference code, code to avoid, or both. For each
5
+ source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
6
+ model) whether the file breaks each rule, gets a calibrated probability per
7
7
  rule, and reports the ones above a threshold with the line Jev points at. The
8
8
  report uses the same frame as `vp lint`.
9
9
 
@@ -54,7 +54,8 @@ Found 1 error.
54
54
  ```
55
55
 
56
56
  The header is the rule id, Jev's probability, and the rule's description. The
57
- hint is the rule's reference. The exit code is 0 when nothing is reported and 1
57
+ hint is the rule's reference; a rule with only code to avoid shows that code,
58
+ labeled `avoid:`, instead. The exit code is 0 when nothing is reported and 1
58
59
  when something is. It is also 1 when the run refuses, for example on an invalid
59
60
  config or rule file, a missing `TYPESAFE_API_KEY`, or an unknown command or
60
61
  flag; a refusal prints its reason.
@@ -128,6 +129,7 @@ export default {
128
129
  const UserId = Schema.String.pipe(Schema.brand("UserId"))
129
130
  type UserId = typeof UserId.Type
130
131
  `,
132
+ avoid: "type UserId = string", // optional: what a violation looks like
131
133
  threshold: 0.8, // optional per-rule override
132
134
  },
133
135
  },
@@ -154,7 +156,8 @@ directory exists, it is read without any config.
154
156
 
155
157
  A file is front matter, then a body. The first fenced code block in the body
156
158
  is the reference; prose around it renders on GitHub and is ignored. Without a
157
- fence, the whole body is the reference.
159
+ fence, the whole body is the reference. A fence tagged `avoid` after its
160
+ language holds code to avoid instead: what a violation looks like.
158
161
 
159
162
  ````md
160
163
  ---
@@ -170,11 +173,25 @@ const Port = Schema.Int.pipe(
170
173
  Schema.brand("Port"),
171
174
  );
172
175
  ```
176
+
177
+ Not this:
178
+
179
+ ```ts avoid
180
+ const port: number = Number(process.env.PORT);
181
+ ```
173
182
  ````
174
183
 
175
- `description` is required. `threshold` is optional. A file that fails
176
- validation refuses the run with its path in the message. `loadRules(directory)`
177
- from the package root does the same load for your own tooling.
184
+ `description` is required, with a reference, code to avoid, or both.
185
+ `threshold` is optional. A file that fails validation refuses the run with its
186
+ path in the message. `loadRules(directory)` from the package root does the same
187
+ load for your own tooling.
188
+
189
+ With a reference, Jev is asked whether the file diverges from it, and code to
190
+ avoid, when there is some, is its example of diverging. With only code to
191
+ avoid, Jev is asked whether the file contains it. That suits a rule with no
192
+ single correct form to show, such as a hand-rolled retry loop or an error
193
+ caught and dropped. Neither goes in the other's place: code to avoid in the
194
+ reference reads to Jev as the pattern to follow.
178
195
 
179
196
  Nested `.adhere/` directories are also discovered, except under
180
197
  `node_modules/`, `dist/`, and the other skipped directories (above). A rule in
@@ -221,8 +238,10 @@ beats all of the above for that rule.
221
238
  ## How a file is judged
222
239
 
223
240
  1. One Jev request per file, with the file's numbered lines and every rule as
224
- the state, and one `noul` (yes/no probability) question per rule. Every
225
- question shares the state cost of the request.
241
+ the state, and one `noul` (yes/no probability) question per rule: whether
242
+ the file diverges from the reference or, for a rule with only code to
243
+ avoid, whether it contains that code. Every question shares the state cost
244
+ of the request.
226
245
  2. A second request only when at least one rule's probability is above its
227
246
  threshold: one `choice` question per flagged rule over the file's non-blank
228
247
  lines, which yields the line to report. A file with more than 255 non-blank
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.3.1",
3
+ "version": "0.4.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.3.1",
71
- "@drkmttr/adhere-darwin-x64": "0.3.1",
72
- "@drkmttr/adhere-linux-arm64": "0.3.1",
73
- "@drkmttr/adhere-linux-x64": "0.3.1",
74
- "@drkmttr/adhere-win32-x64": "0.3.1"
70
+ "@drkmttr/adhere-darwin-arm64": "0.4.0",
71
+ "@drkmttr/adhere-darwin-x64": "0.4.0",
72
+ "@drkmttr/adhere-linux-arm64": "0.4.0",
73
+ "@drkmttr/adhere-linux-x64": "0.4.0",
74
+ "@drkmttr/adhere-win32-x64": "0.4.0"
75
75
  }
76
76
  }
@@ -61,15 +61,22 @@ Optional prose for readers on GitHub. adhere ignores it.
61
61
  ```ts
62
62
  // Correct code lifted from this repo, trimmed to the pattern.
63
63
  ```
64
+
65
+ ```ts avoid
66
+ // Optional: the incorrect form this rule catches, as it appeared in the repo.
67
+ ```
64
68
  ````
65
69
 
66
70
  Rules for the rule:
67
71
 
68
72
  - `description` is one sentence, positive, specific. Jev reads it with the
69
73
  reference; vague words ("properly", "correctly") give it nothing to judge.
70
- - The reference is real code from the repo, not invented. Never include
71
- incorrect code; Jev compares files to the reference, so a "bad" example
72
- teaches the wrong thing.
74
+ - The reference is real code from the repo, not invented, and only correct
75
+ code: Jev compares files to it, so incorrect code there teaches the wrong
76
+ thing. Incorrect code goes in a fence tagged `avoid`, which Jev reads as what
77
+ a violation looks like. Add one when violations have a recognizable shape,
78
+ ideally one found in the repo's history. A rule with no single correct form
79
+ can be only an `avoid` block.
73
80
  - One pattern per file. Two patterns in one reference blur the probability.
74
81
  - `threshold` is optional. Start without it; set it in step 5 if needed.
75
82
 
package/src/config.ts CHANGED
@@ -6,9 +6,18 @@ export type RuleId = string;
6
6
 
7
7
  export const Rule = Schema.Struct({
8
8
  description: Schema.String,
9
- reference: Schema.String,
9
+ /** Correct code: the pattern a file should follow. */
10
+ reference: Schema.optionalKey(Schema.String),
11
+ /** Incorrect code: what a violation looks like. */
12
+ avoid: Schema.optionalKey(Schema.String),
10
13
  threshold: Schema.optionalKey(Schema.Finite),
11
- });
14
+ }).check(
15
+ Schema.makeFilter((rule) =>
16
+ rule.reference !== undefined || rule.avoid !== undefined
17
+ ? undefined
18
+ : "a rule needs a reference, code to avoid, or both",
19
+ ),
20
+ );
12
21
  export interface Rule extends Schema.Schema.Type<typeof Rule> {}
13
22
 
14
23
  export type Rules = Readonly<Record<RuleId, Rule>>;
package/src/init.ts CHANGED
@@ -43,6 +43,16 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
43
43
  return yield* CustomerStore.find(customerId);
44
44
  });
45
45
  \`\`\`
46
+
47
+ A fence tagged \`avoid\` shows what a violation looks like. It is optional, and
48
+ a rule can have one without the block above.
49
+
50
+ \`\`\`ts avoid
51
+ export const handle = (id: string) =>
52
+ Effect.gen(function* () {
53
+ return yield* CustomerStore.find(id);
54
+ });
55
+ \`\`\`
46
56
  `;
47
57
 
48
58
  const CONFIG_PATH = CONFIG_FILES[0];
package/src/markdown.ts CHANGED
@@ -14,8 +14,13 @@ import { Effect, Schema } from "effect";
14
14
  * const reference = "the correct code"
15
15
  * ```
16
16
  *
17
- * The reference is the first fenced code block. Without a fence, it is the
18
- * whole body. `threshold` is optional.
17
+ * ```ts avoid
18
+ * const avoided = "what a violation looks like"
19
+ * ```
20
+ *
21
+ * The reference is the first fenced code block; `avoid` in a fence's info
22
+ * string marks the code to avoid instead. A rule has either or both. Without a
23
+ * fence, the whole body is the reference. `threshold` is optional.
19
24
  */
20
25
  const FrontMatter = Schema.Struct({
21
26
  description: Schema.String,
@@ -23,7 +28,8 @@ const FrontMatter = Schema.Struct({
23
28
  });
24
29
 
25
30
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
26
- const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n {0,3}\1[ \t]*$/m;
31
+ const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
32
+ const AVOID = "avoid";
27
33
 
28
34
  const unquote = (value: string): string => {
29
35
  const trimmed = value.trim();
@@ -44,9 +50,35 @@ const parseFrontMatter = (block: string): Record<string, string> => {
44
50
  return fields;
45
51
  };
46
52
 
47
- const referenceOf = (body: string): string => {
48
- const fence = FENCE.exec(body);
49
- return (fence?.[2] ?? body).trim();
53
+ interface Fence {
54
+ readonly avoid: boolean;
55
+ readonly code: string;
56
+ }
57
+
58
+ const fencesOf = (body: string): ReadonlyArray<Fence> =>
59
+ Array.from(body.matchAll(FENCE), (match) => ({
60
+ avoid: (match[2] ?? "")
61
+ .trim()
62
+ .split(/\s+/)
63
+ .some((word) => word.toLowerCase() === AVOID),
64
+ code: (match[3] ?? "").trim(),
65
+ }));
66
+
67
+ /** An empty block counts as no block. */
68
+ const nonEmpty = (code: string | undefined): string | undefined =>
69
+ code === undefined || code.length === 0 ? undefined : code;
70
+
71
+ /** The reference and the code to avoid, each left out when the body has none. */
72
+ const codeOf = (body: string): Pick<Rule, "reference" | "avoid"> => {
73
+ const fences = fencesOf(body);
74
+ const reference = nonEmpty(
75
+ fences.length === 0 ? body.trim() : fences.find((fence) => !fence.avoid)?.code,
76
+ );
77
+ const avoid = nonEmpty(fences.find((fence) => fence.avoid)?.code);
78
+ return {
79
+ ...(reference === undefined ? {} : { reference }),
80
+ ...(avoid === undefined ? {} : { avoid }),
81
+ };
50
82
  };
51
83
 
52
84
  export const parseRuleMarkdown = (
@@ -65,11 +97,11 @@ export const parseRuleMarkdown = (
65
97
  ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
66
98
  ),
67
99
  );
68
- const reference = referenceOf(match[2]);
69
- if (reference.length === 0) {
100
+ const code = codeOf(match[2]);
101
+ if (code.reference === undefined && code.avoid === undefined) {
70
102
  return yield* ConfigUnavailable.make({
71
- message: `${file}: the body is the reference code and cannot be empty`,
103
+ message: `${file}: the body needs reference code, a fence tagged avoid, or both`,
72
104
  });
73
105
  }
74
- return { ...front, reference };
106
+ return { ...front, ...code };
75
107
  });
@@ -1,4 +1,4 @@
1
- import type { RuleId, Rules } from "#config.ts";
1
+ import type { Rule, RuleId, Rules } from "#config.ts";
2
2
  import { Context, type Effect, Record, Schema } from "effect";
3
3
 
4
4
  export type { Rules };
@@ -36,12 +36,36 @@ export const needsBlocks = (lines: Lines): boolean =>
36
36
 
37
37
  const stateOf = (lines: Lines, rules: Rules) => ({
38
38
  code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
39
- rules: Record.map(rules, ({ description, reference }) => ({
39
+ rules: Record.map(rules, ({ description, reference, avoid }) => ({
40
40
  description,
41
- reference,
41
+ ...(reference === undefined ? {} : { reference }),
42
+ ...(avoid === undefined ? {} : { avoid }),
42
43
  })),
43
44
  });
44
45
 
46
+ const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
47
+ `state.rules["${id}"].${key}`;
48
+
49
+ /**
50
+ * A rule with a reference asks whether the file diverges from it, with the
51
+ * code to avoid as an example when there is some. A rule without one asks
52
+ * whether the file contains the code to avoid.
53
+ */
54
+ const judgeInstructions = (id: RuleId, rule: Rule): string =>
55
+ rule.reference === undefined
56
+ ? `Does state.code contain the pattern shown in ${field(id, "avoid")}, which ${field(id, "description")} rules out? Answer no if nothing in this file resembles it.`
57
+ : `Does state.code diverge from the pattern shown in ${field(id, "reference")}${
58
+ rule.avoid === undefined ? "" : `, for example by doing what ${field(id, "avoid")} shows`
59
+ }, as described by ${field(id, "description")}? Answer no if the pattern does not apply to this file.`;
60
+
61
+ /** What the offending line does, for the line and block questions. */
62
+ const offense = (id: RuleId, rule: Rule): string =>
63
+ rule.reference === undefined
64
+ ? `shows the pattern in ${field(id, "avoid")}`
65
+ : rule.avoid === undefined
66
+ ? `diverges from ${field(id, "reference")}`
67
+ : `diverges from ${field(id, "reference")} or resembles ${field(id, "avoid")}`;
68
+
45
69
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
46
70
  const criteria: Record<string, string> = {};
47
71
  for (let n = from; n <= Math.min(to, lines.length); n++) {
@@ -54,9 +78,9 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
54
78
  export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
55
79
  model,
56
80
  state: stateOf(lines, rules),
57
- questions: Record.map(rules, (_, id) => ({
81
+ questions: Record.map(rules, (rule, id) => ({
58
82
  type: "noul" as const,
59
- instructions: `Does state.code diverge from the pattern shown in state.rules["${id}"].reference, as described by state.rules["${id}"].description? Answer no if the pattern does not apply to this file.`,
83
+ instructions: judgeInstructions(id, rule),
60
84
  })),
61
85
  });
62
86
 
@@ -68,11 +92,11 @@ export const locateBody = (
68
92
  ) => ({
69
93
  model,
70
94
  state: stateOf(lines, rules),
71
- questions: Record.map(rules, (_, id) => {
95
+ questions: Record.map(rules, (rule, id) => {
72
96
  const block = blocks?.[id];
73
97
  return {
74
98
  type: "choice" as const,
75
- instructions: `Which line of state.code most clearly diverges from state.rules["${id}"].reference?`,
99
+ instructions: `Which line of state.code most clearly ${offense(id, rule)}?`,
76
100
  criteria:
77
101
  block === undefined
78
102
  ? lineCriteria(lines, 1, lines.length)
@@ -92,9 +116,9 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
92
116
  return {
93
117
  model,
94
118
  state: stateOf(lines, rules),
95
- questions: Record.map(rules, (_, id) => ({
119
+ questions: Record.map(rules, (rule, id) => ({
96
120
  type: "choice" as const,
97
- instructions: `Which block of state.code contains the line that most clearly diverges from state.rules["${id}"].reference?`,
121
+ instructions: `Which block of state.code contains the line that most clearly ${offense(id, rule)}?`,
98
122
  criteria,
99
123
  })),
100
124
  };
@@ -10,7 +10,8 @@ import { type Crypto, Effect, Record } from "effect";
10
10
  export interface Finding {
11
11
  readonly rule: RuleId;
12
12
  readonly description: string;
13
- readonly reference: string;
13
+ readonly reference?: string;
14
+ readonly avoid?: string;
14
15
  readonly file: string;
15
16
  readonly line: number;
16
17
  readonly snippet: string;
@@ -43,6 +44,19 @@ const fileResult = (
43
44
 
44
45
  const isEmpty = Record.isEmptyReadonlyRecord;
45
46
 
47
+ /**
48
+ * What a judgment depends on besides the file. A rule without code to avoid
49
+ * hashes what every rule hashed before `avoid` existed, so its cached
50
+ * judgments survive the upgrade.
51
+ */
52
+ const fingerprintOf = (model: string, rule: Rule) =>
53
+ sha256(
54
+ model +
55
+ rule.description +
56
+ (rule.reference ?? "") +
57
+ (rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
58
+ );
59
+
46
60
  export const runAudit: Effect.Effect<
47
61
  AuditResult,
48
62
  WalkUnavailable | JevUnavailable,
@@ -66,7 +80,7 @@ export const runAudit: Effect.Effect<
66
80
  Object.entries(configuredRules(file.path)),
67
81
  ([id, rule]) =>
68
82
  Effect.map(
69
- sha256(config.model + rule.description + rule.reference),
83
+ fingerprintOf(config.model, rule),
70
84
  (fingerprint) =>
71
85
  [
72
86
  id,
@@ -116,6 +130,7 @@ export const runAudit: Effect.Effect<
116
130
  rule: id,
117
131
  description: k.rule.description,
118
132
  reference: k.rule.reference,
133
+ avoid: k.rule.avoid,
119
134
  file: file.path,
120
135
  line: judgment.line,
121
136
  snippet: judgment.snippet ?? "",
@@ -24,8 +24,8 @@ export interface RenderOptions {
24
24
 
25
25
  const isBlank = (line: string): boolean => line.trim().length === 0;
26
26
 
27
- const referenceLines = (reference: string): ReadonlyArray<string> => {
28
- const lines = reference.split("\n");
27
+ const codeLines = (code: string): ReadonlyArray<string> => {
28
+ const lines = code.split("\n");
29
29
  const first = lines.findIndex((line) => !isBlank(line));
30
30
  if (first < 0) return [];
31
31
  let last = lines.length - 1;
@@ -36,12 +36,16 @@ const referenceLines = (reference: string): ReadonlyArray<string> => {
36
36
  /**
37
37
  * One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
38
38
  * the offending line, a pink underline, and the reference code as the hint.
39
+ * A rule without a reference shows its code to avoid instead, labeled so.
39
40
  */
40
41
  const frame = (finding: Finding, options: RenderOptions): ReadonlyArray<string> => {
41
42
  const color = options.color === true;
42
43
  const digits = String(finding.line);
43
44
  const gutter = " ".repeat(digits.length + 2);
44
- const [hint = "", ...rest] = referenceLines(finding.reference);
45
+ const [label, code] =
46
+ finding.reference === undefined ? ["avoid", finding.avoid ?? ""] : ["hint", finding.reference];
47
+ const prefix = ` ${label}: `;
48
+ const [hint = "", ...rest] = codeLines(code);
45
49
  const header = `${finding.rule} (${finding.probability.toFixed(2)})`;
46
50
  return [
47
51
  ` ${red("×", color)} ${red(header, color)}: ${red(finding.description, color)}`,
@@ -49,8 +53,8 @@ const frame = (finding: Finding, options: RenderOptions): ReadonlyArray<string>
49
53
  ` ${dim(digits, color)} │ ${finding.snippet}`,
50
54
  `${gutter}· ${pink("─".repeat(Math.max(1, finding.snippet.trim().length)), color)}`,
51
55
  `${gutter}╰────`,
52
- `${helpTint(" hint: ", color)}${hint}`,
53
- ...rest.map((line) => (isBlank(line) ? "" : ` ${line}`)),
56
+ `${helpTint(prefix, color)}${hint}`,
57
+ ...rest.map((line) => (isBlank(line) ? "" : `${" ".repeat(prefix.length)}${line}`)),
54
58
  ];
55
59
  };
56
60