@drkmttr/adhere 0.13.1 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.13.1",
72
- "@drkmttr/adhere-darwin-x64": "0.13.1",
73
- "@drkmttr/adhere-linux-arm64": "0.13.1",
74
- "@drkmttr/adhere-linux-x64": "0.13.1",
75
- "@drkmttr/adhere-win32-x64": "0.13.1"
71
+ "@drkmttr/adhere-darwin-arm64": "0.14.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.14.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.14.0",
74
+ "@drkmttr/adhere-linux-x64": "0.14.0",
75
+ "@drkmttr/adhere-win32-x64": "0.14.0"
76
76
  }
77
77
  }
@@ -53,7 +53,8 @@ config in `.adhere/` are not rules.
53
53
  description: A port must be a branded, range-checked integer, never a bare number.
54
54
  ---
55
55
 
56
- Prose here renders on GitHub and is ignored.
56
+ Prose here is the rule's details: Jev reads it after the description. Say why
57
+ the rule holds, or where it does not apply.
57
58
 
58
59
  ## Must
59
60
 
@@ -88,6 +89,9 @@ How to word a rule, from adhere's evals:
88
89
  - One pattern per rule. Two in one block blur the probability.
89
90
  - A heading names its code only when it is the word alone: `## Never`, not
90
91
  `## Never do this`. A fence tagged `ts never` names its code itself.
92
+ - Prose outside the code is sent to Jev with the description, so keep it about
93
+ the rule: why it holds, or where it does not apply. A note for the people who
94
+ maintain the rule, rather than for judging code, belongs elsewhere.
91
95
 
92
96
  Front matter besides `description`, all optional:
93
97
 
@@ -102,12 +106,14 @@ Front matter besides `description`, all optional:
102
106
  one line, scope a rule without widening its description: a finding stands
103
107
  only when Jev says the code that breaks the rule is as every `appliesTo`
104
108
  describes and as no `excludeIf` does.
105
- - `includeWorkspacePackages: true` gives Jev `workspacePackages` beside the
106
- code: each of the repository's own packages, by name, with its directory,
107
- read from the root `package.json`'s `workspaces` or `pnpm-workspace.yaml`.
108
- Use it for a rule that turns on whether an import is of the repository's
109
- own package or an installed one, and name `workspacePackages` in the
110
- description. The rule costs a request of its own per file, and a changed
109
+ - `reads: ["workspacePackages"]`, a JSON array on one line, names what Jev
110
+ reads for the rule beside the code, each under that name. There is one so
111
+ far, `workspacePackages`: each of the repository's own packages, by name,
112
+ with its directory, read from the root `package.json`'s `workspaces` or
113
+ `pnpm-workspace.yaml`. Use it for a rule that turns on whether an import is
114
+ of the repository's own package or an installed one, and name
115
+ `workspacePackages` in the description. A name adhere does not have refuses
116
+ the run. The rule costs a request of its own per file, and a changed
111
117
  workspace judges it again.
112
118
 
113
119
  A rule's directory can hold a `RULE.ts` instead, which default-exports
@@ -38,8 +38,15 @@ wherever this skill says `adhere`, run it the manager's way, as in
38
38
  With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
39
39
 
40
40
  In a repo without a `package.json`, use the `adhere` on PATH (`adhere
41
- --version`), or **Ask** before installing it globally with the manager the
42
- user has, such as `bun add --global @drkmttr/adhere`.
41
+ --version`), or **Ask** before installing the executable from the latest
42
+ release (`adhere-Windows-x86_64.exe` on Windows), which needs no Node or
43
+ package manager:
44
+
45
+ ```sh
46
+ curl -fsSL --create-dirs -o ~/.local/bin/adhere \
47
+ https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
48
+ chmod +x ~/.local/bin/adhere
49
+ ```
43
50
 
44
51
  Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
45
52
  by `adhere login`, which the user runs; never ask for the key in chat or print
@@ -205,9 +212,18 @@ jobs:
205
212
 
206
213
  Pin the versions the repo already uses: bun's `bun-version`, Node's
207
214
  `node-version` from `.nvmrc` or `engines`, and pnpm's from `packageManager`,
208
- which `pnpm/action-setup` reads. In a repo without a `package.json`, set up
209
- the manager the user has and run adhere at the version they ran locally, as
210
- in `bunx @drkmttr/adhere@0.11.0 lint --yes`.
215
+ which `pnpm/action-setup` reads. In a repo without a `package.json`, skip the
216
+ setup and install, and fetch the executable at the version the user ran
217
+ locally:
218
+
219
+ ```yaml
220
+ - run: |
221
+ curl -fsSL -o "$RUNNER_TEMP/adhere" https://github.com/darkmatter/adhere/releases/download/v0.14.0/adhere-$(uname -s)-$(uname -m)
222
+ chmod +x "$RUNNER_TEMP/adhere"
223
+ "$RUNNER_TEMP/adhere" lint --yes
224
+ env:
225
+ TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
226
+ ```
211
227
 
212
228
  Add `--deny-warnings` if the user wants warnings to fail the job too. Match
213
229
  the branch to the repo's default branch. For another CI system, write the
package/src/config.ts CHANGED
@@ -32,6 +32,15 @@ export type AppendState = (
32
32
  bun: BunApi,
33
33
  ) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
34
34
 
35
+ /**
36
+ * What a rule can have Jev read beside the code, each by the name of the key
37
+ * the state carries it under, which the rule's description can name in
38
+ * backticks. `workspacePackages` is the workspace's packages: each one's name
39
+ * with its directory, so a rule can tell an import of the repository's own
40
+ * package from an installed one's.
41
+ */
42
+ export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
43
+
35
44
  /**
36
45
  * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
37
46
  * requirement shows code that `must` be written and code that must `never`
@@ -40,6 +49,13 @@ export type AppendState = (
40
49
  */
41
50
  const RuleFields = Schema.Struct({
42
51
  description: Schema.String,
52
+ /**
53
+ * What the description leaves out, such as why the rule holds or where it
54
+ * does not: in a `RULE.md`, the prose around its code. Jev reads it after
55
+ * the description, as part of the rule; the report shows the description
56
+ * alone.
57
+ */
58
+ details: Schema.optionalKey(Schema.String),
43
59
  must: Schema.optionalKey(Schema.String),
44
60
  never: Schema.optionalKey(Schema.String),
45
61
  should: Schema.optionalKey(Schema.String),
@@ -68,13 +84,11 @@ const RuleFields = Schema.Struct({
68
84
  */
69
85
  excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
70
86
  /**
71
- * Whether Jev reads, beside the code, the workspace's packages as
72
- * `workspacePackages`: each one's name with its directory, so the rule can
73
- * tell an import of the repository's own package from an installed one's.
74
- * The rule's questions go in requests of their own, and a change to the
75
- * packages judges it again.
87
+ * What Jev reads for the rule beside the code, by name (see `Reads`). The
88
+ * rule's questions go in requests of their own, so only they read it, and
89
+ * a change to what is read judges the rule again.
76
90
  */
77
- includeWorkspacePackages: Schema.optionalKey(Schema.Boolean),
91
+ reads: Schema.optionalKey(Reads),
78
92
  /**
79
93
  * A hook on the state Jev reads for the rule, in a rule written in
80
94
  * TypeScript (see `AppendState`). The rule's questions go in requests of
@@ -306,8 +320,8 @@ export interface ResolvedConfig {
306
320
  readonly includeComments?: boolean;
307
321
  /**
308
322
  * The workspace's packages, each name with its directory from the working
309
- * directory, read from its manifests on every run, when a rule asks for
310
- * them with `includeWorkspacePackages`.
323
+ * directory, read from its manifests on every run, when a rule's `reads`
324
+ * names them.
311
325
  */
312
326
  readonly workspacePackages?: Readonly<Record<string, string>>;
313
327
  }
package/src/init.ts CHANGED
@@ -68,9 +68,8 @@ description: A file should be small and focused, with one primary responsibility
68
68
  threshold: 0.8
69
69
  ---
70
70
 
71
- A guideline, so its description and its headings say should and should not. A
72
- rule that must hold says must and never instead, as name-domain-actions does.
73
- Use this rule as a template for repository-specific conventions.
71
+ Why: a file that does one thing is easier to find, read, and change. A helper
72
+ that serves only that thing belongs in the same file.
74
73
 
75
74
  ## Should
76
75
 
@@ -93,6 +92,9 @@ description: A function must be named after the domain action it performs, never
93
92
  threshold: 0.8
94
93
  ---
95
94
 
95
+ Why: a name like \`handle\` or \`process\` says nothing about what the function
96
+ does, so every caller has to open it to find out.
97
+
96
98
  ## Must
97
99
 
98
100
  \`\`\`ts
@@ -104,9 +106,6 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
104
106
 
105
107
  ## Never
106
108
 
107
- The code under Never shows what a violation looks like. It is optional, and a
108
- rule can have it without the code under Must.
109
-
110
109
  \`\`\`ts
111
110
  export const handle = (id: string) =>
112
111
  Effect.gen(function* () {
package/src/markdown.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
@@ -8,7 +8,7 @@ import { Effect, Schema } from "effect";
8
8
  * description: One sentence saying what code must be, or never be.
9
9
  * threshold: 0.8
10
10
  * ---
11
- * Any prose, rendered on GitHub and ignored by adhere.
11
+ * Any prose, which Jev reads after the description, as the rule's details.
12
12
  *
13
13
  * ## Must
14
14
  *
@@ -34,6 +34,8 @@ import { Effect, Schema } from "effect";
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
35
  * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
36
  * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
37
+ * So is `reads`, of the names of what Jev reads for the rule beside the code,
38
+ * as in `reads: ["workspacePackages"]`.
37
39
  */
38
40
  const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
39
41
 
@@ -44,7 +46,7 @@ const FrontMatter = Schema.Struct({
44
46
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
45
47
  appliesTo: Schema.optionalKey(Matchers),
46
48
  excludeIf: Schema.optionalKey(Matchers),
47
- includeWorkspacePackages: Schema.optionalKey(Schema.Literals(["true", "false"])),
49
+ reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
48
50
  });
49
51
 
50
52
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
@@ -149,6 +151,24 @@ const fencesOf = (body: string): ReadonlyArray<Fence> => {
149
151
  const nonEmpty = (code: string | undefined): string | undefined =>
150
152
  code === undefined || code.length === 0 ? undefined : code;
151
153
 
154
+ /**
155
+ * The body's prose: what is left outside its fences and the headings that
156
+ * name their code, which Jev reads after the description. A body without a
157
+ * fence is all code, so it has none.
158
+ */
159
+ const detailsOf = (body: string): string | undefined =>
160
+ fencesOf(body).length === 0
161
+ ? undefined
162
+ : nonEmpty(
163
+ body
164
+ .replace(FENCE, "")
165
+ .replace(HEADING, (line, _level, text: string | undefined) =>
166
+ headingWordOf(text ?? "") === undefined ? line : "",
167
+ )
168
+ .replace(/\n{3,}/g, "\n\n")
169
+ .trim(),
170
+ );
171
+
152
172
  type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
153
173
 
154
174
  /**
@@ -207,12 +227,6 @@ export const parseRuleMarkdown = (
207
227
  message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
208
228
  });
209
229
  }
210
- const { includeWorkspacePackages, ...fields } = front;
211
- return {
212
- ...fields,
213
- ...(includeWorkspacePackages === undefined
214
- ? {}
215
- : { includeWorkspacePackages: includeWorkspacePackages === "true" }),
216
- ...code,
217
- };
230
+ const details = detailsOf(match[2]);
231
+ return { ...front, ...(details === undefined ? {} : { details }), ...code };
218
232
  });
@@ -133,7 +133,7 @@ export const AdhereConfigLive = (flags: Flags) =>
133
133
  const scopedRules = withOverrides(entries, overrides);
134
134
  // The manifests are read on every run, so a changed workspace is seen, and only when a rule reads them.
135
135
  const workspacePackages = scopedRules.some(
136
- (entry) => entry.rule.includeWorkspacePackages === true,
136
+ (entry) => entry.rule.reads?.includes("workspacePackages") === true,
137
137
  )
138
138
  ? yield* workspacePackagesOf(cwd)
139
139
  : undefined;
@@ -68,9 +68,9 @@ const refused = (message: string) => JevUnavailable.make({ message });
68
68
  const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
69
69
 
70
70
  /**
71
- * A body as it goes out: when its rule has `includeWorkspacePackages`, with
72
- * the workspace's packages in its state, and when it has `appendState`, with
73
- * what the hook returns spread over that. A hook that fails refuses the run.
71
+ * A body as it goes out: when its rule's `reads` names the workspace's
72
+ * packages, with them in its state, and when it has `appendState`, with what
73
+ * the hook returns spread over that. A hook that fails refuses the run.
74
74
  */
75
75
  const hooked = (
76
76
  sent: Body & { readonly state: JevState },
@@ -78,7 +78,9 @@ const hooked = (
78
78
  file: JudgedFile,
79
79
  workspacePackages: Readonly<Record<string, string>>,
80
80
  ): Effect.Effect<Body, JevUnavailable> => {
81
- const body = Object.values(group).some((rule) => rule.includeWorkspacePackages === true)
81
+ const body = Object.values(group).some(
82
+ (rule) => rule.reads?.includes("workspacePackages") === true,
83
+ )
82
84
  ? { ...sent, state: { ...sent.state, workspacePackages } }
83
85
  : sent;
84
86
  const [hook] = Object.entries(group).flatMap(([id, rule]) =>
@@ -160,11 +160,15 @@ const exampleFields = (rule: Rule): Record<string, string> => {
160
160
  };
161
161
  };
162
162
 
163
- /** The rule as its questions carry it: the description, and its examples under their words. */
164
- const ruleFields = (rule: Rule) => ({ rule: rule.description, ...exampleFields(rule) });
163
+ /** The rule in words, as Jev reads it: its description, then its details when it has any. */
164
+ const ruleText = (rule: Rule): string =>
165
+ rule.details === undefined ? rule.description : `${rule.description}\n\n${rule.details}`;
166
+
167
+ /** The rule as its questions carry it: its text, and its examples under their words. */
168
+ const ruleFields = (rule: Rule) => ({ rule: ruleText(rule), ...exampleFields(rule) });
165
169
 
166
170
  /** A rule's text as a comparison of rules reads it. */
167
- const ruleState = (rule: Rule) => ({ description: rule.description, ...exampleFields(rule) });
171
+ const ruleState = (rule: Rule) => ({ description: ruleText(rule), ...exampleFields(rule) });
168
172
 
169
173
  /**
170
174
  * The compared rules a question mentions, keyed by index: ids alone would
@@ -477,9 +481,9 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
477
481
  return requests.map(({ questions }) => ({ ...body, questions }));
478
482
  };
479
483
 
480
- /** Whether a rule reads state of its own: what its `appendState` adds, or the workspace's packages. */
484
+ /** Whether a rule reads state of its own: what its `appendState` adds, or what its `reads` names. */
481
485
  const readsOwnState = (rule: Rule): boolean =>
482
- rule.appendState !== undefined || rule.includeWorkspacePackages === true;
486
+ rule.appendState !== undefined || (rule.reads ?? []).length > 0;
483
487
 
484
488
  /**
485
489
  * The rules as requests carry them: every rule that reads only the code in
@@ -125,7 +125,9 @@ const fingerprintOf = (
125
125
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
126
126
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
127
127
  const packages =
128
- rule.includeWorkspacePackages === true ? `\u0000${JSON.stringify(workspacePackages)}` : "";
128
+ rule.reads?.includes("workspacePackages") === true
129
+ ? `\u0000${JSON.stringify(workspacePackages)}`
130
+ : "";
129
131
  return sha256(
130
132
  `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
131
133
  );