@drkmttr/adhere 0.6.1 → 0.7.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 (36) hide show
  1. package/README.md +59 -31
  2. package/package.json +7 -7
  3. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  4. package/presets/effect/basics/fn-for-named-effects.md +10 -2
  5. package/presets/effect/basics/gen-for-sequencing.md +10 -2
  6. package/presets/effect/basics/instrument-with-pipe.md +9 -2
  7. package/presets/effect/cli/handlers-delegate.md +13 -2
  8. package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
  9. package/presets/effect/config/secrets-are-redacted.md +6 -2
  10. package/presets/effect/config/tests-provide-values-directly.md +8 -2
  11. package/presets/effect/config/validate-with-schema.md +9 -2
  12. package/presets/effect/data/brand-meaningful-primitives.md +11 -2
  13. package/presets/effect/data/decode-json-with-schema.md +6 -2
  14. package/presets/effect/data/records-are-schema-classes.md +9 -2
  15. package/presets/effect/data/variants-are-tagged-unions.md +9 -2
  16. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  17. package/presets/effect/errors/defects-for-bugs.md +13 -2
  18. package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
  19. package/presets/effect/errors/wrap-external-errors.md +7 -2
  20. package/presets/effect/platform/os-access-through-platform-services.md +8 -2
  21. package/presets/effect/services/fresh-layer-per-test.md +9 -2
  22. package/presets/effect/services/memoize-parameterized-layers.md +9 -2
  23. package/presets/effect/services/methods-have-no-requirements.md +9 -2
  24. package/presets/effect/services/no-mutable-state.md +9 -2
  25. package/presets/effect/services/provide-at-entry.md +7 -2
  26. package/presets/effect/services/test-layers-are-in-memory.md +12 -2
  27. package/presets/effect/testing/test-clock-for-time.md +11 -2
  28. package/presets/effect/testing/test-random.md +9 -2
  29. package/skills/adhere/SKILL.md +24 -17
  30. package/src/cli.ts +2 -2
  31. package/src/config.ts +67 -13
  32. package/src/init.ts +10 -8
  33. package/src/markdown.ts +59 -25
  34. package/src/services/Jev.ts +45 -31
  35. package/src/workflows/audit.ts +3 -5
  36. package/src/workflows/format.ts +7 -3
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Service method signatures have no requirements. Dependencies should be acquired inside the layer, not declared on the method.
2
+ description: Service method signatures must have no requirements. Dependencies must be acquired inside the layer, never declared on the method.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  class Database extends Context.Service<
7
7
  Database,
8
8
  {
@@ -11,3 +11,10 @@ class Database extends Context.Service<
11
11
  }
12
12
  >()("@app/Database") {}
13
13
  ```
14
+
15
+ ```ts never
16
+ class Orders extends Context.Service<
17
+ Orders,
18
+ { readonly save: (order: Order) => Effect.Effect<void, SaveError, Database> }
19
+ >()("@app/Orders") {}
20
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A service exposes readonly members and does not expose mutable state.
2
+ description: A service must expose only readonly members, never mutable state.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  class Logger extends Context.Service<
7
7
  Logger,
8
8
  {
@@ -10,3 +10,10 @@ class Logger extends Context.Service<
10
10
  }
11
11
  >()("@app/Logger") {}
12
12
  ```
13
+
14
+ ```ts never
15
+ class Counter extends Context.Service<
16
+ Counter,
17
+ { count: number; readonly increment: () => Effect.Effect<void> }
18
+ >()("@app/Counter") {}
19
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Layers should be provided once at the program entry. A module that is not an entry point does not call Effect.provide.
2
+ description: Layers must be provided once at the program entry. A module that is not an entry point must never call Effect.provide.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const appLayer = userServiceLayer.pipe(
7
7
  Layer.provideMerge(databaseLayer),
8
8
  Layer.provideMerge(loggerLayer),
@@ -16,3 +16,8 @@ const program = Effect.gen(function* () {
16
16
 
17
17
  const main = program.pipe(Effect.provide(appLayer));
18
18
  ```
19
+
20
+ ```ts never
21
+ export const getUser = (id: string) =>
22
+ UserRepo.find(id).pipe(Effect.provide(UserRepo.layer));
23
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test implementation of a service should be built with Layer.sync or Layer.succeed over in-memory state.
2
+ description: A test implementation of a service must be built with Layer.sync or Layer.succeed over in-memory state, never over a real database, network, or file.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  static readonly testLayer = Layer.sync(Cache, () => {
7
7
  const store = new Map<string, string>()
8
8
 
@@ -12,3 +12,13 @@ static readonly testLayer = Layer.sync(Cache, () => {
12
12
  return { get, set }
13
13
  })
14
14
  ```
15
+
16
+ ```ts never
17
+ const EmailTest = Layer.effect(
18
+ Email,
19
+ Effect.gen(function* () {
20
+ const smtp = yield* SmtpClient;
21
+ return Email.of({ send: (message) => smtp.deliver(message) });
22
+ }),
23
+ ).pipe(Layer.provide(SmtpClient.layer({ host: "localhost", port: 1025 })));
24
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test that depends on time uses TestClock. Real sleeps and it.live should be used only when real time is required.
2
+ description: A test that depends on time must use TestClock. Real sleeps and it.live must be used only when real time is required.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  it.effect("time-based test", () =>
7
7
  Effect.gen(function* () {
8
8
  const fiber = yield* Effect.delay(Effect.succeed("done"), "10 seconds").pipe(Effect.forkChild);
@@ -12,3 +12,12 @@ it.effect("time-based test", () =>
12
12
  }),
13
13
  );
14
14
  ```
15
+
16
+ ```ts never
17
+ it.live("retries after a delay", () =>
18
+ Effect.gen(function* () {
19
+ yield* Effect.sleep("2 seconds");
20
+ expect(yield* attempts).toBe(2);
21
+ }),
22
+ );
23
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test that depends on randomness controls it with a fixed seed through Random.withSeed, not Math.random or an unseeded Random.
2
+ description: A test that depends on randomness must control it with a fixed seed through Random.withSeed, never with Math.random or an unseeded Random.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const program = Effect.gen(function* () {
7
7
  const value1 = yield* Random.next;
8
8
  const value2 = yield* Random.next;
@@ -13,3 +13,10 @@ await Effect.runPromise(
13
13
  Effect.all([program.pipe(Random.withSeed("my-seed")), program.pipe(Random.withSeed("my-seed"))]),
14
14
  ); // => [[0.018368576514773527, 0.4010840628128671], [0.018368576514773527, 0.4010840628128671]]
15
15
  ```
16
+
17
+ ```ts never
18
+ it("creates unique ids", () => {
19
+ const id = Math.random().toString(36).slice(2);
20
+ expect(id).toHaveLength(11);
21
+ });
22
+ ```
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: adhere
3
- description: Gather a repository's non-deterministic coding conventions into adhere rules (Markdown files with a description and reference code judged by Jev), configure adhere, run it, and calibrate thresholds. Use when setting up adhere in a repo, when the user asks to turn conventions, AGENTS.md guidance, ADRs, or review feedback into lint rules a normal linter cannot express, or when tuning adhere findings.
3
+ description: Gather a repository's non-deterministic coding conventions into adhere rules (Markdown files with a description and code that must, or must never, be written, judged by Jev), configure adhere, run it, and calibrate thresholds. Use when setting up adhere in a repo, when the user asks to turn conventions, AGENTS.md guidance, ADRs, or review feedback into lint rules a normal linter cannot express, or when tuning adhere findings.
4
4
  ---
5
5
 
6
6
  # Gather adhere rules from a repo
7
7
 
8
8
  adhere is a linter for rules a normal linter cannot check. Each rule is one
9
- sentence plus a block of correct code; Jev judges every source file against it
10
- and returns a probability. Your job is to find the conventions a repo already
9
+ sentence in RFC 2119's words plus code that must be written and code that must
10
+ never be; Jev judges every source file against it and returns a probability. Your job is to find the conventions a repo already
11
11
  has, write the ones that need judgment as rule files, and tune until the
12
12
  report is trustworthy.
13
13
 
@@ -52,32 +52,39 @@ The path without `.md` is the rule id.
52
52
 
53
53
  ````md
54
54
  ---
55
- description: One sentence stating the pattern positively. Name what is done, then what it replaces.
55
+ description: One sentence saying what code must be, then what it must never be.
56
56
  threshold: 0.8
57
57
  ---
58
58
 
59
59
  Optional prose for readers on GitHub. adhere ignores it.
60
60
 
61
- ```ts
61
+ ```ts must
62
62
  // Correct code lifted from this repo, trimmed to the pattern.
63
63
  ```
64
64
 
65
- ```ts avoid
65
+ ```ts never
66
66
  // Optional: the incorrect form this rule catches, as it appeared in the repo.
67
67
  ```
68
68
  ````
69
69
 
70
70
  Rules for the rule:
71
71
 
72
- - `description` is one sentence, positive, specific. Jev reads it with the
73
- reference; vague words ("properly", "correctly") give it nothing to judge.
74
- - The reference is real code from the repo, not invented, and only correct
72
+ - `description` is one sentence, specific, in the same words as the fences:
73
+ what code must be, and what it must never be. Jev reads the description
74
+ with the code, and "must" in both is one demand; vague words ("properly",
75
+ "correctly") give it nothing to judge.
76
+ - The `must` block is real code from the repo, not invented, and only correct
75
77
  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.
80
- - One pattern per file. Two patterns in one reference blur the probability.
78
+ thing. Incorrect code goes in a fence tagged `never`, which Jev reads as what
79
+ a violation looks like. Add one whenever violations have a recognizable
80
+ shape, ideally one found in the repo's history: over the effect preset, a
81
+ `never` block raised how well Jev told violations from compliant code under
82
+ every wording tried. A rule with no single correct form can be only a
83
+ `never` block.
84
+ - A convention that is a guideline rather than a requirement says "should" in
85
+ its description and tags its fences `should` and `should not`. A rule is one
86
+ or the other; it cannot mix the two.
87
+ - One pattern per file. Two patterns in one `must` block blur the probability.
81
88
  - `threshold` is optional. Start without it; set it in step 5 if needed.
82
89
 
83
90
  ## 4. Configure
@@ -110,11 +117,11 @@ adhere lint --threshold 0.9 # re-reads the cache, sends nothing
110
117
 
111
118
  Then, per rule, read the findings and decide:
112
119
 
113
- - Many hits at 0.70 to 0.80, mostly not violations: the description is too
120
+ - Many hits at 0.80 to 0.90, mostly not violations: the description is too
114
121
  broad. Narrow it (say what is out of scope) or raise that rule's
115
122
  `threshold`. Prefer narrowing; a threshold hides, a sentence explains.
116
- - Hits on code that follows the pattern through a different API: the
117
- reference is too specific. Use the repo's most general correct example.
123
+ - Hits on code that follows the pattern through a different API: the `must`
124
+ block is too specific. Use the repo's most general correct example.
118
125
  - Zero hits: plant a deliberate violation in a scratch file, run, confirm the
119
126
  rule fires, remove the file. A rule that cannot fire is not a rule.
120
127
  - Hits at 0.9 and above: read them first. They are usually real.
package/src/cli.ts CHANGED
@@ -154,7 +154,7 @@ export const lintCommand = Command.make("lint", { preset, threshold, yes }, (inp
154
154
  }),
155
155
  ).pipe(
156
156
  Command.withDescription(
157
- "Audit the working directory against reference code from .adhere/config.ts or a preset, judged by Jev.",
157
+ "Audit the working directory against the rules in .adhere/ or a preset, judged by Jev.",
158
158
  ),
159
159
  Command.provide((input) =>
160
160
  auditLayer({
@@ -274,7 +274,7 @@ export const skillCommand = Command.make("skill", {}, () => Console.log(skill.tr
274
274
 
275
275
  /** The whole CLI. Bare `adhere` prints its help. */
276
276
  export const cli = Command.make("adhere").pipe(
277
- Command.withDescription("Lint a repository against reference code in .adhere/, judged by Jev."),
277
+ Command.withDescription("Lint a repository against the rules in .adhere/, judged by Jev."),
278
278
  Command.withExamples([
279
279
  { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
280
280
  { command: "adhere validate", description: "Check the config and rules without calling Jev" },
package/src/config.ts CHANGED
@@ -1,25 +1,79 @@
1
1
  import { type PresetName, presetNames } from "#presets.ts";
2
2
  import type { RuleSet } from "#rules.ts";
3
- import { Effect, Schema } from "effect";
3
+ import { Effect, Schema, SchemaTransformation } from "effect";
4
4
 
5
5
  export type RuleId = string;
6
6
 
7
- export const Rule = Schema.Struct({
7
+ /**
8
+ * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
9
+ * requirement shows code that `must` be written and code that must `never`
10
+ * be. A guideline, which asks for less, shows code that `should` be written
11
+ * and code that `shouldNot` be.
12
+ */
13
+ const RuleFields = Schema.Struct({
8
14
  description: Schema.String,
9
- /** Correct code: the pattern a file should follow. */
15
+ must: Schema.optionalKey(Schema.String),
16
+ never: Schema.optionalKey(Schema.String),
17
+ should: Schema.optionalKey(Schema.String),
18
+ shouldNot: Schema.optionalKey(Schema.String),
19
+ threshold: Schema.optionalKey(Schema.Finite),
20
+ });
21
+
22
+ /** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
23
+ export const Rule = Schema.Struct({
24
+ ...RuleFields.fields,
10
25
  reference: Schema.optionalKey(Schema.String),
11
- /** Incorrect code: what a violation looks like. */
12
26
  avoid: Schema.optionalKey(Schema.String),
13
- threshold: Schema.optionalKey(Schema.Finite),
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
- );
27
+ })
28
+ .pipe(
29
+ Schema.decodeTo(
30
+ RuleFields,
31
+ SchemaTransformation.transform({
32
+ decode: ({ reference, avoid, ...rule }) => ({
33
+ ...rule,
34
+ ...(rule.must === undefined && reference !== undefined ? { must: reference } : {}),
35
+ ...(rule.never === undefined && avoid !== undefined ? { never: avoid } : {}),
36
+ }),
37
+ encode: (rule) => rule,
38
+ }),
39
+ ),
40
+ )
41
+ .check(
42
+ Schema.makeFilter((rule) => {
43
+ const requirement = rule.must !== undefined || rule.never !== undefined;
44
+ const guideline = rule.should !== undefined || rule.shouldNot !== undefined;
45
+ return requirement && guideline
46
+ ? "a rule is a requirement (must, never) or a guideline (should, shouldNot), not both"
47
+ : requirement || guideline
48
+ ? undefined
49
+ : "a rule needs an example: must, never, should, or shouldNot";
50
+ }),
51
+ );
21
52
  export interface Rule extends Schema.Schema.Type<typeof Rule> {}
22
53
 
54
+ /** One of a rule's examples, under the word it is written with. */
55
+ export interface Example {
56
+ readonly word: "must" | "never" | "should" | "should not";
57
+ readonly code: string;
58
+ }
59
+
60
+ /** A rule's code to write and code not to write, each when the rule has it. */
61
+ export interface Examples {
62
+ readonly good?: Example;
63
+ readonly bad?: Example;
64
+ }
65
+
66
+ /** A guideline's examples are `should` and `should not`; any other rule's, `must` and `never`. */
67
+ export const examplesOf = (rule: Rule): Examples => {
68
+ const guideline = rule.should !== undefined || rule.shouldNot !== undefined;
69
+ const good = guideline ? rule.should : rule.must;
70
+ const bad = guideline ? rule.shouldNot : rule.never;
71
+ return {
72
+ ...(good === undefined ? {} : { good: { word: guideline ? "should" : "must", code: good } }),
73
+ ...(bad === undefined ? {} : { bad: { word: guideline ? "should not" : "never", code: bad } }),
74
+ };
75
+ };
76
+
23
77
  export type Rules = Readonly<Record<RuleId, Rule>>;
24
78
 
25
79
  /** Rules inline, or a directory of Markdown rule files (see `markdown.ts`). */
@@ -86,7 +140,7 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
86
140
  };
87
141
 
88
142
  export const DEFAULT_MODEL = "jev-latest";
89
- export const DEFAULT_THRESHOLD = 0.7;
143
+ export const DEFAULT_THRESHOLD = 0.8;
90
144
 
91
145
  /** A config with its presets folded in and every default applied. */
92
146
  export interface ResolvedConfig {
package/src/init.ts CHANGED
@@ -15,39 +15,41 @@ export interface InitOptions {
15
15
  const CONFIG = `import type { Config } from "@drkmttr/adhere";
16
16
 
17
17
  export default {
18
- threshold: 0.7,
18
+ threshold: 0.8,
19
19
  } satisfies Config;
20
20
  `;
21
21
 
22
22
  const SMALL_FILES = `---
23
- description: Prefer small focused files with one primary responsibility.
23
+ description: A file should be small and focused, with one primary responsibility.
24
24
  threshold: 0.8
25
25
  ---
26
26
 
27
- Use this rule as a template for repository-specific conventions.
27
+ A guideline, so its description and its code say should. A rule that must
28
+ hold says must and never instead, as name-domain-actions does. Use this rule
29
+ as a template for repository-specific conventions.
28
30
 
29
- \`\`\`ts
31
+ \`\`\`ts should
30
32
  export const parsePort = (value: string) =>
31
33
  Port.make(Number.parseInt(value, 10));
32
34
  \`\`\`
33
35
  `;
34
36
 
35
37
  const NAME_EFFECTS = `---
36
- description: Name functions after the domain action they perform.
38
+ description: A function must be named after the domain action it performs, never after a generic verb like handle.
37
39
  threshold: 0.8
38
40
  ---
39
41
 
40
- \`\`\`ts
42
+ \`\`\`ts must
41
43
  export const loadCustomerProfile = (customerId: CustomerId) =>
42
44
  Effect.gen(function* () {
43
45
  return yield* CustomerStore.find(customerId);
44
46
  });
45
47
  \`\`\`
46
48
 
47
- A fence tagged \`avoid\` shows what a violation looks like. It is optional, and
49
+ A fence tagged \`never\` shows what a violation looks like. It is optional, and
48
50
  a rule can have one without the block above.
49
51
 
50
- \`\`\`ts avoid
52
+ \`\`\`ts never
51
53
  export const handle = (id: string) =>
52
54
  Effect.gen(function* () {
53
55
  return yield* CustomerStore.find(id);
package/src/markdown.ts CHANGED
@@ -1,26 +1,28 @@
1
- import { ConfigUnavailable, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
5
5
  * A rule as a Markdown file:
6
6
  *
7
7
  * ---
8
- * description: One sentence naming the pattern.
8
+ * description: One sentence saying what code must be, or never be.
9
9
  * threshold: 0.8
10
10
  * ---
11
11
  * Any prose, rendered on GitHub and ignored by adhere.
12
12
  *
13
- * ```ts
14
- * const reference = "the correct code"
13
+ * ```ts must
14
+ * const written = "code that must be written this way"
15
15
  * ```
16
16
  *
17
- * ```ts avoid
18
- * const avoided = "what a violation looks like"
17
+ * ```ts never
18
+ * const avoided = "code that must never be written this way"
19
19
  * ```
20
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.
21
+ * A fence's info string names its code with RFC 2119's words: `must` and
22
+ * `never` for a requirement, `should` and `should not` for a guideline. The
23
+ * first fence under each word is that example, and a rule has either or both.
24
+ * An untagged fence is the code to write, and without a fence the whole body
25
+ * is. `avoid`, the word before 0.7, means `never`. `threshold` is optional.
24
26
  */
25
27
  const FrontMatter = Schema.Struct({
26
28
  description: Schema.String,
@@ -29,7 +31,6 @@ const FrontMatter = Schema.Struct({
29
31
 
30
32
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
31
33
  const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
32
- const AVOID = "avoid";
33
34
 
34
35
  const unquote = (value: string): string => {
35
36
  const trimmed = value.trim();
@@ -50,17 +51,27 @@ const parseFrontMatter = (block: string): Record<string, string> => {
50
51
  return fields;
51
52
  };
52
53
 
54
+ type Word = Example["word"];
55
+
53
56
  interface Fence {
54
- readonly avoid: boolean;
57
+ /** The word the info string names the code with; none for an untagged fence. */
58
+ readonly word: Word | undefined;
55
59
  readonly code: string;
56
60
  }
57
61
 
62
+ /** `must not`, like `never` and the older `avoid`, names code never to write. */
63
+ const wordOf = (info: string): Word | undefined => {
64
+ const words = info.trim().toLowerCase().split(/\s+/);
65
+ const not = words.includes("not");
66
+ if (words.includes("never") || words.includes("avoid")) return "never";
67
+ if (words.includes("should")) return not ? "should not" : "should";
68
+ if (words.includes("must")) return not ? "never" : "must";
69
+ return undefined;
70
+ };
71
+
58
72
  const fencesOf = (body: string): ReadonlyArray<Fence> =>
59
73
  Array.from(body.matchAll(FENCE), (match) => ({
60
- avoid: (match[2] ?? "")
61
- .trim()
62
- .split(/\s+/)
63
- .some((word) => word.toLowerCase() === AVOID),
74
+ word: wordOf(match[2] ?? ""),
64
75
  code: (match[3] ?? "").trim(),
65
76
  }));
66
77
 
@@ -68,16 +79,32 @@ const fencesOf = (body: string): ReadonlyArray<Fence> =>
68
79
  const nonEmpty = (code: string | undefined): string | undefined =>
69
80
  code === undefined || code.length === 0 ? undefined : code;
70
81
 
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"> => {
82
+ type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
83
+
84
+ /**
85
+ * Each example the body has, under its word. An untagged fence is the code
86
+ * to write: `should` in a guideline, which a `should` or `should not` fence
87
+ * makes the rule, and `must` otherwise.
88
+ */
89
+ const codeOf = (body: string): Code => {
73
90
  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);
91
+ if (fences.length === 0) {
92
+ const must = nonEmpty(body.trim());
93
+ return must === undefined ? {} : { must };
94
+ }
95
+ const first = (word: Word | undefined) =>
96
+ nonEmpty(fences.find((fence) => fence.word === word)?.code);
97
+ const guideline = fences.some((fence) => fence.word === "should" || fence.word === "should not");
98
+ const untagged = first(undefined);
99
+ const must = first("must") ?? (guideline ? undefined : untagged);
100
+ const never = first("never");
101
+ const should = first("should") ?? (guideline ? untagged : undefined);
102
+ const shouldNot = first("should not");
78
103
  return {
79
- ...(reference === undefined ? {} : { reference }),
80
- ...(avoid === undefined ? {} : { avoid }),
104
+ ...(must === undefined ? {} : { must }),
105
+ ...(never === undefined ? {} : { never }),
106
+ ...(should === undefined ? {} : { should }),
107
+ ...(shouldNot === undefined ? {} : { shouldNot }),
81
108
  };
82
109
  };
83
110
 
@@ -98,9 +125,16 @@ export const parseRuleMarkdown = (
98
125
  ),
99
126
  );
100
127
  const code = codeOf(match[2]);
101
- if (code.reference === undefined && code.avoid === undefined) {
128
+ const requirement = code.must !== undefined || code.never !== undefined;
129
+ const guideline = code.should !== undefined || code.shouldNot !== undefined;
130
+ if (requirement && guideline) {
131
+ return yield* ConfigUnavailable.make({
132
+ message: `${file}: a rule is a requirement (must, never) or a guideline (should, should not), not both`,
133
+ });
134
+ }
135
+ if (!requirement && !guideline) {
102
136
  return yield* ConfigUnavailable.make({
103
- message: `${file}: the body needs reference code, a fence tagged avoid, or both`,
137
+ message: `${file}: the body needs code, in a fence tagged must, never, should, or should not, or untagged`,
104
138
  });
105
139
  }
106
140
  return { ...front, ...code };
@@ -1,4 +1,4 @@
1
- import type { Rule, RuleId, Rules } from "#config.ts";
1
+ import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
2
2
  import { Context, type Effect, Record, Schema } from "effect";
3
3
 
4
4
  export type { Rules };
@@ -91,19 +91,23 @@ const stateOf = (lines: Lines) => ({
91
91
  code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
92
92
  });
93
93
 
94
- /** The rule as its questions carry it: the description, and its code on either side. */
95
- const ruleFields = (rule: Rule) => ({
96
- rule: rule.description,
97
- ...(rule.reference === undefined ? {} : { reference: rule.reference }),
98
- ...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
99
- });
94
+ /** The field an example goes under in a question: its word, with `should not` as `should_not`. */
95
+ const fieldOf = (example: Example): string => example.word.replace(" ", "_");
100
96
 
101
- /** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
102
- const ruleState = ({ description, reference, avoid }: Rule) => ({
103
- description,
104
- ...(reference === undefined ? {} : { reference }),
105
- ...(avoid === undefined ? {} : { avoid }),
106
- });
97
+ /** A rule's examples under their words. Code a rule lacks is left out, not sent empty. */
98
+ const exampleFields = (rule: Rule): Record<string, string> => {
99
+ const { good, bad } = examplesOf(rule);
100
+ return {
101
+ ...(good === undefined ? {} : { [fieldOf(good)]: good.code }),
102
+ ...(bad === undefined ? {} : { [fieldOf(bad)]: bad.code }),
103
+ };
104
+ };
105
+
106
+ /** The rule as its questions carry it: the description, and its examples under their words. */
107
+ const ruleFields = (rule: Rule) => ({ rule: rule.description, ...exampleFields(rule) });
108
+
109
+ /** A rule's text as a comparison of rules reads it. */
110
+ const ruleState = (rule: Rule) => ({ description: rule.description, ...exampleFields(rule) });
107
111
 
108
112
  /**
109
113
  * The compared rules a question mentions, keyed by index: ids alone would
@@ -122,24 +126,31 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
122
126
 
123
127
  /**
124
128
  * Whether the file breaks the rule. The rule rides in the question, not the
125
- * state, so each question reads its own rule and no other. A rule with a
126
- * reference asks whether the file diverges from it, with the code to avoid
127
- * as an example when there is some. A rule without one asks whether the file
128
- * contains the code to avoid. The criteria draw the line at the rule's scope:
129
- * a file with no code the rule is about is a no.
129
+ * state, so each question reads its own rule and no other, its code under its
130
+ * own words: `must` and `never`, or a guideline's `should` and `should_not`.
131
+ * A rule with code to write asks whether the file diverges from it, with the
132
+ * code not to write as an example when there is some. A rule without any
133
+ * asks whether the file contains the code not to write. The criteria draw the
134
+ * line at the rule's scope: a file with no code the rule is about is a no.
135
+ *
136
+ * On the eval, "diverge from the pattern shown in `must`" judged better than
137
+ * asking whether code "must be written the way `must` shows": the same words,
138
+ * but a pattern to compare rather than code to match.
130
139
  */
131
140
  export const judgeQuestion = (rule: Rule) => {
132
- const example = rule.avoid === undefined ? "" : ", for example by doing what `avoid` shows";
141
+ const { good, bad } = examplesOf(rule);
142
+ const avoided = fieldOf(bad ?? { word: "never", code: "" });
143
+ const example = bad === undefined ? "" : `, for example by doing what \`${avoided}\` shows`;
133
144
  const [question, yes, no] =
134
- rule.reference === undefined
145
+ good === undefined
135
146
  ? [
136
- "Does `code` contain the pattern shown in `avoid`, which `rule` rules out? Answer no if nothing in this file resembles it.",
137
- "`code` contains the pattern shown in `avoid`",
138
- "`code` has nothing like `avoid`",
147
+ `Does \`code\` contain the pattern shown in \`${avoided}\`, which \`rule\` rules out? Answer no if nothing in this file resembles it.`,
148
+ `\`code\` contains the pattern shown in \`${avoided}\``,
149
+ `\`code\` has nothing like \`${avoided}\``,
139
150
  ]
140
151
  : [
141
- `Does \`code\` diverge from the pattern shown in \`reference\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
142
- `\`code\` diverges from the pattern shown in \`reference\`${example}`,
152
+ `Does \`code\` diverge from the pattern shown in \`${fieldOf(good)}\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
153
+ `\`code\` diverges from the pattern shown in \`${fieldOf(good)}\`${example}`,
143
154
  "`code` follows the pattern",
144
155
  ];
145
156
  return {
@@ -153,12 +164,15 @@ export const judgeQuestion = (rule: Rule) => {
153
164
  };
154
165
 
155
166
  /** What the offending line does, for the line and block questions. */
156
- const offense = (rule: Rule): string =>
157
- rule.reference === undefined
158
- ? "shows the pattern in `avoid`"
159
- : rule.avoid === undefined
160
- ? "diverges from `reference`"
161
- : "diverges from `reference` or resembles `avoid`";
167
+ const offense = (rule: Rule): string => {
168
+ const { good, bad } = examplesOf(rule);
169
+ const avoided = `\`${fieldOf(bad ?? { word: "never", code: "" })}\``;
170
+ return good === undefined
171
+ ? `shows the pattern in ${avoided}`
172
+ : bad === undefined
173
+ ? `diverges from \`${fieldOf(good)}\``
174
+ : `diverges from \`${fieldOf(good)}\` or resembles ${avoided}`;
175
+ };
162
176
 
163
177
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
164
178
  const criteria: Record<string, string> = {};