@drkmttr/adhere 0.3.0 → 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.
@@ -78,10 +79,11 @@ prints a completion script.
78
79
 
79
80
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
80
81
  `.test.ts`, and config files. When the working directory contains `agents/`,
81
- `apps/`, or `packages/`, only those trees are read. Paths under
82
- `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
83
- `.adhere/`, `.agents/`, `.claude/`, `.direnv/`, `.alchemy/`, or `.vite/` are
84
- skipped. `.tsx` files are not read, and `.gitignore` is not consulted.
82
+ `apps/`, or `packages/`, only those trees are read. Below the working
83
+ directory, anything under `node_modules/`, `dist/`, `coverage/`, `vendor/`,
84
+ `e2e/`, `references/`, `.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
85
+ `.alchemy/`, or `.vite/` is skipped; the directories above it do not count.
86
+ `.tsx` files are not read, and `.gitignore` is not consulted.
85
87
 
86
88
  ### Init
87
89
 
@@ -127,6 +129,7 @@ export default {
127
129
  const UserId = Schema.String.pipe(Schema.brand("UserId"))
128
130
  type UserId = typeof UserId.Type
129
131
  `,
132
+ avoid: "type UserId = string", // optional: what a violation looks like
130
133
  threshold: 0.8, // optional per-rule override
131
134
  },
132
135
  },
@@ -153,7 +156,8 @@ directory exists, it is read without any config.
153
156
 
154
157
  A file is front matter, then a body. The first fenced code block in the body
155
158
  is the reference; prose around it renders on GitHub and is ignored. Without a
156
- 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.
157
161
 
158
162
  ````md
159
163
  ---
@@ -169,13 +173,28 @@ const Port = Schema.Int.pipe(
169
173
  Schema.brand("Port"),
170
174
  );
171
175
  ```
176
+
177
+ Not this:
178
+
179
+ ```ts avoid
180
+ const port: number = Number(process.env.PORT);
181
+ ```
172
182
  ````
173
183
 
174
- `description` is required. `threshold` is optional. A file that fails
175
- validation refuses the run with its path in the message. `loadRules(directory)`
176
- 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.
177
195
 
178
- Nested `.adhere/` directories are also discovered. A rule in
196
+ Nested `.adhere/` directories are also discovered, except under
197
+ `node_modules/`, `dist/`, and the other skipped directories (above). A rule in
179
198
  `packages/api/.adhere/data/brand-ports.md` has the same id,
180
199
  `data/brand-ports`, but applies only to files under `packages/api/`. Root
181
200
  `.adhere/` rules apply project-wide. If a nested rule has the same id as a root
@@ -219,8 +238,10 @@ beats all of the above for that rule.
219
238
  ## How a file is judged
220
239
 
221
240
  1. One Jev request per file, with the file's numbered lines and every rule as
222
- the state, and one `noul` (yes/no probability) question per rule. Every
223
- 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.
224
245
  2. A second request only when at least one rule's probability is above its
225
246
  threshold: one `choice` question per flagged rule over the file's non-blank
226
247
  lines, which yields the line to report. A file with more than 255 non-blank
@@ -306,5 +327,6 @@ before it can be given trusted publishers on npmjs.com. npm checks the
306
327
  workflow that started the run, so every package has two: `release.yaml`, which
307
328
  calls the publish workflow for tag releases, and `publish.yaml`, for manual
308
329
  runs. The publish workflow can be run manually for an already-created release
309
- tag if a publish needs to be retried; it skips platform packages whose version
310
- is already on npm.
330
+ tag if a publish needs to be retried; it skips every package whose version is
331
+ already on npm. The main package's `files` list keeps it to `bin/`, `src/`,
332
+ `presets/`, and `skills/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.3.0",
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": {
@@ -17,6 +17,12 @@
17
17
  "exports": {
18
18
  ".": "./src/index.ts"
19
19
  },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "presets",
24
+ "skills"
25
+ ],
20
26
  "publishConfig": {
21
27
  "access": "public"
22
28
  },
@@ -61,10 +67,10 @@
61
67
  }
62
68
  },
63
69
  "optionalDependencies": {
64
- "@drkmttr/adhere-darwin-arm64": "0.3.0",
65
- "@drkmttr/adhere-darwin-x64": "0.3.0",
66
- "@drkmttr/adhere-linux-arm64": "0.3.0",
67
- "@drkmttr/adhere-linux-x64": "0.3.0",
68
- "@drkmttr/adhere-win32-x64": "0.3.0"
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"
69
75
  }
70
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>>;
@@ -38,6 +47,25 @@ export const CONFIG_FILES = [
38
47
  "adhere.config.ts",
39
48
  ".adhere.config.ts",
40
49
  ] as const;
50
+ /**
51
+ * Directories never read into, for source files or for nested `.adhere/`
52
+ * rules: dependency, generated, vendored, and tool trees. Matched as whole
53
+ * path segments below the working directory, so a repo that itself sits under
54
+ * one of these names is still read.
55
+ */
56
+ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
57
+ "node_modules",
58
+ "dist",
59
+ ".agents",
60
+ ".claude",
61
+ ".direnv",
62
+ ".alchemy",
63
+ "coverage",
64
+ ".vite",
65
+ "references",
66
+ "vendor",
67
+ "e2e",
68
+ ]);
41
69
  export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
42
70
 
43
71
  /** The shape a config file default-exports. */
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
  });
package/src/rules.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { ConfigUnavailable, type Rule, type RuleId, type RuleSource } from "#config.ts";
1
+ import {
2
+ ConfigUnavailable,
3
+ type Rule,
4
+ type RuleId,
5
+ type RuleSource,
6
+ SKIPPED_DIRECTORIES,
7
+ } from "#config.ts";
2
8
  import { parseRuleMarkdown } from "#markdown.ts";
3
9
  import { Effect, FileSystem, Path, Record } from "effect";
4
10
 
@@ -31,7 +37,13 @@ const isMarkdown = (path: string): boolean => path.endsWith(".md");
31
37
  const isNestedRuleFile = (relative: string): boolean => {
32
38
  const segments = segmentsOf(relative);
33
39
  const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
34
- return adhere >= 0 && segments[adhere + 1] !== CACHE_SEGMENT && isMarkdown(relative);
40
+ return (
41
+ adhere >= 0 &&
42
+ segments[adhere + 1] !== CACHE_SEGMENT &&
43
+ isMarkdown(relative) &&
44
+ // Only the path to the `.adhere/`: `.adhere/e2e/` is a rule topic, not a skip.
45
+ !segments.slice(0, adhere).some((segment) => SKIPPED_DIRECTORIES.has(segment))
46
+ );
35
47
  };
36
48
 
37
49
  const rulePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
@@ -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
  };
@@ -1,4 +1,5 @@
1
1
  import { Context, Effect, FileSystem, Layer, Path } from "effect";
2
+ import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
2
3
  import type { ScannedFile } from "#models/Audit.ts";
3
4
  import { WalkUnavailable } from "#models/Audit.ts";
4
5
 
@@ -20,21 +21,15 @@ export class SourceWalker extends Context.Service<
20
21
  * trees. Otherwise read the working directory itself.
21
22
  */
22
23
  const WORKSPACE_DIRS = ["agents", "apps", "packages"] as const;
23
- /** Path fragments that mark generated, vendored, or dependency trees. */
24
- const SKIPS = [
25
- "/node_modules/",
26
- "/.adhere/",
27
- "/dist/",
28
- "/.agents/",
29
- "/.claude/",
30
- "/.direnv/",
31
- "/.alchemy/",
32
- "/coverage/",
33
- "/.vite/",
34
- "/references/",
35
- "/vendor/",
36
- "/e2e/",
37
- ] as const;
24
+ /**
25
+ * A path, relative to the root being read, through a skipped directory or
26
+ * through `.adhere/`, whose files are rules and cache rather than source.
27
+ * Relative, so the directories above the working directory never count.
28
+ */
29
+ export const isInSkippedTree = (relative: string): boolean =>
30
+ relative
31
+ .split(/[\\/]/)
32
+ .some((segment) => segment === ADHERE_DIRECTORY || SKIPPED_DIRECTORIES.has(segment));
38
33
 
39
34
  const isInside = (file: string, root: string): boolean =>
40
35
  file === root || file.startsWith(`${root}/`);
@@ -45,7 +40,6 @@ const isScannable = (file: string, self: string): boolean =>
45
40
  !file.endsWith(".test.ts") &&
46
41
  !file.endsWith("/adhere.config.ts") &&
47
42
  !file.endsWith("/.adhere.config.ts") &&
48
- SKIPS.every((skip) => !file.includes(skip)) &&
49
43
  // The audit's own detector patterns are data, not violations of themselves.
50
44
  !isInside(file, self);
51
45
 
@@ -57,7 +51,10 @@ const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) =
57
51
 
58
52
  /** What the root actually contains: only paths the audit reads. */
59
53
  const scannable = (root: string, paths: ReadonlyArray<string>, self: string) =>
60
- rooted(root, paths).filter((file) => isScannable(file, self));
54
+ rooted(
55
+ root,
56
+ paths.filter((relative) => !isInSkippedTree(relative)),
57
+ ).filter((file) => isScannable(file, self));
61
58
 
62
59
  /** One root's paths, filtered to what the audit reads. */
63
60
  const listRoot = Effect.fn("SourceWalker.listRoot")(
@@ -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
 
package/.adhere/config.ts DELETED
@@ -1,7 +0,0 @@
1
- import type { Config } from "../src/index.ts";
2
-
3
- // Type-only import: erased at runtime, so the compiled binary loads this file
4
- // without resolving the package (see README, "Configuration").
5
- export default {
6
- presets: ["effect"],
7
- } satisfies Config;
@@ -1,17 +0,0 @@
1
- ---
2
- description: An error raised while reading a config, rule, or cache file names that file in its message, so the user can open it.
3
- threshold: 0.8
4
- ---
5
-
6
- Adhere's own rule. A refusal is the only thing the user sees, so it points
7
- at the file it is about.
8
-
9
- ```ts
10
- const front =
11
- yield *
12
- Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
13
- Effect.mapError((problem) =>
14
- ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
15
- ),
16
- );
17
- ```
@@ -1,27 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- push:
6
- branches: [main]
7
-
8
- jobs:
9
- verify:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
- - uses: oven-sh/setup-bun@v2
14
- with:
15
- bun-version: "1.4.2"
16
- - run: bun install --frozen-lockfile
17
- - run: bun run typecheck
18
- - run: bun run test
19
- - run: bun run build:npm
20
- - name: Run the launcher against the linux-x64 package
21
- run: |
22
- # Outside the checkout, where the launcher has no source to fall back to.
23
- launcher="$RUNNER_TEMP/launcher"
24
- mkdir -p "$launcher/node_modules/@drkmttr"
25
- cp -r bin "$launcher/bin"
26
- ln -s "$PWD/dist/npm/linux-x64" "$launcher/node_modules/@drkmttr/adhere-linux-x64"
27
- node "$launcher/bin/adhere.js" --version