@drkmttr/adhere 0.9.5 → 0.11.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 (113) hide show
  1. package/README.md +406 -90
  2. package/package.json +7 -7
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
  7. package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
  8. package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
  9. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  10. package/presets/alchemy/data/single-migration-owner.md +6 -2
  11. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  12. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  13. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  14. package/presets/alchemy/durable/stable-step-names.md +6 -2
  15. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  16. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  17. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  18. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  19. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  20. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  21. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  22. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  23. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  24. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  25. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  26. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  27. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  28. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  29. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  30. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  31. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  32. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  33. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  34. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  35. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  36. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  37. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  38. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  39. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  40. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  41. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  42. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  43. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  44. package/presets/effect/basics/gen-for-sequencing.md +7 -3
  45. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  46. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  47. package/presets/effect/config/secrets-are-redacted.md +6 -2
  48. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  49. package/presets/effect/config/validate-with-schema.md +6 -2
  50. package/presets/effect/data/brand-meaningful-primitives.md +7 -3
  51. package/presets/effect/data/variants-are-tagged-unions.md +7 -3
  52. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  53. package/presets/effect/errors/defects-for-bugs.md +6 -2
  54. package/presets/effect/errors/wrap-external-errors.md +6 -2
  55. package/presets/effect/services/dependencies-through-layers.md +7 -3
  56. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  57. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  58. package/presets/effect/services/no-mutable-state.md +6 -2
  59. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  60. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  61. package/presets/effect/testing/test-clock-for-time.md +7 -2
  62. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  63. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  64. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  65. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  66. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  67. package/presets/react/server/no-private-data-to-client.md +23 -0
  68. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  69. package/presets/react/state/no-contradictory-state.md +17 -0
  70. package/presets/react/state/no-props-copied-into-state.md +23 -0
  71. package/presets/react/state/store-ids-not-copies.md +20 -0
  72. package/presets/security/constant-time-secret-compare.md +22 -0
  73. package/presets/security/no-secrets-in-output.md +19 -0
  74. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  75. package/presets/security/parameterized-queries.md +16 -0
  76. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  77. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  78. package/presets/typescript/design/no-argument-mutation.md +20 -0
  79. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  80. package/presets/typescript/errors/release-on-every-path.md +29 -0
  81. package/presets/typescript/testing/assert-outcomes.md +22 -0
  82. package/presets/typescript/testing/independent-tests.md +41 -0
  83. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  84. package/presets/typescript/types/derive-dont-restate.md +24 -0
  85. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  86. package/presets/typescript/types/validate-external-data.md +21 -0
  87. package/skills/adhere/SKILL.md +41 -12
  88. package/skills/adhere-fix/SKILL.md +180 -0
  89. package/src/cli.ts +59 -14
  90. package/src/comments.ts +36 -0
  91. package/src/config.ts +126 -14
  92. package/src/contradictions.ts +10 -2
  93. package/src/excerpt.ts +78 -58
  94. package/src/highlight.ts +2 -0
  95. package/src/index.ts +1 -1
  96. package/src/init.ts +119 -12
  97. package/src/main.ts +0 -1
  98. package/src/markdown.ts +83 -14
  99. package/src/models/Audit.ts +2 -0
  100. package/src/presets.ts +12 -3
  101. package/src/rules.ts +36 -3
  102. package/src/services/AdhereConfig.ts +36 -13
  103. package/src/services/AuditCache.ts +31 -5
  104. package/src/services/Jev.http.ts +76 -11
  105. package/src/services/Jev.ts +191 -73
  106. package/src/services/SourceWalker.ts +51 -13
  107. package/src/suppress.ts +119 -0
  108. package/src/version.ts +25 -1
  109. package/src/workflows/audit.ts +192 -40
  110. package/src/workflows/format.ts +71 -24
  111. package/presets/alchemy/data/parameterized-sql.md +0 -13
  112. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
  113. package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/src/main.ts CHANGED
@@ -49,7 +49,6 @@ const report = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
49
49
 
50
50
  Command.run(cli, { version }).pipe(
51
51
  Effect.tapCause(report),
52
- // oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services and the logger.
53
52
  Effect.provide(Layer.merge(BunServices.layer, logger)),
54
53
  (program) => BunRuntime.runMain(program, { disableErrorReporting: true }),
55
54
  );
package/src/markdown.ts CHANGED
@@ -10,27 +10,46 @@ import { Effect, Schema } from "effect";
10
10
  * ---
11
11
  * Any prose, rendered on GitHub and ignored by adhere.
12
12
  *
13
- * ```ts must
13
+ * ## Must
14
+ *
15
+ * ```ts
14
16
  * const written = "code that must be written this way"
15
17
  * ```
16
18
  *
17
- * ```ts never
19
+ * ## Never
20
+ *
21
+ * ```ts
18
22
  * const avoided = "code that must never be written this way"
19
23
  * ```
20
24
  *
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.
25
+ * A heading names the code in its section with RFC 2119's words: `must` and
26
+ * `never` for a requirement, `should` and `should not` for a guideline. So
27
+ * does a fence's info string, as in `ts never`, which GitHub does not show; a
28
+ * fence's own word wins over its heading's. The first fence under each word is
29
+ * that example, and a rule has either or both. An untagged fence outside those
30
+ * sections is the code to write, and without a fence the whole body is.
31
+ * `avoid`, the word before 0.7, means `never`. `threshold` is optional, and
32
+ * so is `tests`: `only` for a rule about test files, which rules otherwise
33
+ * skip, and `include` for a rule that judges them too. So is `level`:
34
+ * `warning` reports the rule's findings as warnings, which do not fail a run.
35
+ * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
+ * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
26
37
  */
38
+ const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
39
+
27
40
  const FrontMatter = Schema.Struct({
28
41
  description: Schema.String,
29
42
  threshold: Schema.optionalKey(Schema.FiniteFromString),
43
+ tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
44
+ level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
45
+ appliesTo: Schema.optionalKey(Matchers),
46
+ excludeIf: Schema.optionalKey(Matchers),
30
47
  });
31
48
 
32
49
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
33
50
  const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
51
+ /** An ATX heading: its level as `#`s, then its text, without a closing run of `#`s. */
52
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/gm;
34
53
 
35
54
  const unquote = (value: string): string => {
36
55
  const trimmed = value.trim();
@@ -54,11 +73,17 @@ const parseFrontMatter = (block: string): Record<string, string> => {
54
73
  type Word = Example["word"];
55
74
 
56
75
  interface Fence {
57
- /** The word the info string names the code with; none for an untagged fence. */
76
+ /** The word its info string, or else its heading, names the code with; none for untagged code. */
58
77
  readonly word: Word | undefined;
59
78
  readonly code: string;
60
79
  }
61
80
 
81
+ interface Heading {
82
+ readonly level: number;
83
+ /** The word the heading names its section's code with; none for any other heading. */
84
+ readonly word: Word | undefined;
85
+ }
86
+
62
87
  /** `must not`, like `never` and the older `avoid`, names code never to write. */
63
88
  const wordOf = (info: string): Word | undefined => {
64
89
  const words = info.trim().toLowerCase().split(/\s+/);
@@ -69,11 +94,55 @@ const wordOf = (info: string): Word | undefined => {
69
94
  return undefined;
70
95
  };
71
96
 
72
- const fencesOf = (body: string): ReadonlyArray<Fence> =>
73
- Array.from(body.matchAll(FENCE), (match) => ({
74
- word: wordOf(match[2] ?? ""),
75
- code: (match[3] ?? "").trim(),
76
- }));
97
+ const HEADING_WORDS: ReadonlySet<string> = new Set([
98
+ "must",
99
+ "must not",
100
+ "never",
101
+ "avoid",
102
+ "should",
103
+ "should not",
104
+ ]);
105
+
106
+ /**
107
+ * A heading names a word only when it is the word alone, in any case and with
108
+ * or without a colon: `## Never` does, `## Why never` does not.
109
+ */
110
+ const headingWordOf = (text: string): Word | undefined => {
111
+ const phrase = text.trim().replace(/:$/, "").trim().toLowerCase().split(/\s+/).join(" ");
112
+ return HEADING_WORDS.has(phrase) ? wordOf(phrase) : undefined;
113
+ };
114
+
115
+ /**
116
+ * The headings open after `text`, outermost first, given those open before
117
+ * it. A heading closes the sections at its level and deeper, so a deeper
118
+ * heading stays inside the section above it.
119
+ */
120
+ const openAfter = (open: ReadonlyArray<Heading>, text: string): ReadonlyArray<Heading> =>
121
+ Array.from(text.matchAll(HEADING)).reduce<ReadonlyArray<Heading>>((headings, match) => {
122
+ const level = (match[1] ?? "#").length;
123
+ return [
124
+ ...headings.filter((heading) => heading.level < level),
125
+ { level, word: headingWordOf(match[2] ?? "") },
126
+ ];
127
+ }, open);
128
+
129
+ /**
130
+ * Each fence, under the word its info string names or, without one, the word
131
+ * of the innermost heading over it that names one.
132
+ */
133
+ const fencesOf = (body: string): ReadonlyArray<Fence> => {
134
+ const fences: Array<Fence> = [];
135
+ let open: ReadonlyArray<Heading> = [];
136
+ let read = 0;
137
+ for (const match of body.matchAll(FENCE)) {
138
+ // Only headings between fences count: a `#` line inside one is code.
139
+ open = openAfter(open, body.slice(read, match.index));
140
+ read = match.index + match[0].length;
141
+ const section = open.findLast((heading) => heading.word !== undefined)?.word;
142
+ fences.push({ word: wordOf(match[2] ?? "") ?? section, code: (match[3] ?? "").trim() });
143
+ }
144
+ return fences;
145
+ };
77
146
 
78
147
  /** An empty block counts as no block. */
79
148
  const nonEmpty = (code: string | undefined): string | undefined =>
@@ -134,7 +203,7 @@ export const parseRuleMarkdown = (
134
203
  }
135
204
  if (!requirement && !guideline) {
136
205
  return yield* ConfigUnavailable.make({
137
- message: `${file}: the body needs code, in a fence tagged must, never, should, or should not, or untagged`,
206
+ message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
138
207
  });
139
208
  }
140
209
  return { ...front, ...code };
@@ -3,6 +3,8 @@ import { Schema } from "effect";
3
3
  export interface ScannedFile {
4
4
  readonly path: string;
5
5
  readonly lines: ReadonlyArray<string>;
6
+ /** A test file, which only the rules that say `tests` judge. */
7
+ readonly test?: boolean;
6
8
  }
7
9
 
8
10
  /** A source walker's refusal: the tree it was asked to read is not readable. */
package/src/presets.ts CHANGED
@@ -4,10 +4,13 @@ import type { Preset } from "#config.ts";
4
4
  * `presets/` sits next to `src/` in a checkout. In a compiled executable
5
5
  * (`bun build --compile --asset ./presets`) Bun embeds it under the bundle's
6
6
  * own directory instead, hence the fork on `Bun.isStandaloneExecutable`, which
7
- * is the check Bun's docs give for this. Measured on Bun 1.4. The `typeof`
8
- * guard is for Vitest, which imports this module on Node.
7
+ * is the check Bun's docs give for this. Measured on Bun 1.4. It is read off
8
+ * `globalThis` for Vitest, which imports this module on Node, and for a
9
+ * project that type-checks this package's source without Bun's types.
9
10
  */
10
- const isStandaloneExecutable = typeof Bun !== "undefined" && Bun.isStandaloneExecutable;
11
+ const isStandaloneExecutable =
12
+ (globalThis as { readonly Bun?: { readonly isStandaloneExecutable?: boolean } }).Bun
13
+ ?.isStandaloneExecutable === true;
11
14
 
12
15
  const presetsRoot = isStandaloneExecutable
13
16
  ? new URL("./presets/", import.meta.url)
@@ -20,6 +23,9 @@ const presetsRoot = isStandaloneExecutable
20
23
  const topics = {
21
24
  effect: ["basics", "config", "data", "errors", "services", "testing"],
22
25
  alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
26
+ typescript: ["async", "design", "errors", "testing", "types"],
27
+ react: ["effects", "server", "state"],
28
+ security: [],
23
29
  } as const;
24
30
 
25
31
  type Topics = typeof topics;
@@ -54,6 +60,9 @@ export const presetOf = (name: PresetName): BuiltInPreset => {
54
60
  export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
55
61
  effect: presetOf("effect"),
56
62
  alchemy: presetOf("alchemy"),
63
+ typescript: presetOf("typescript"),
64
+ react: presetOf("react"),
65
+ security: presetOf("security"),
57
66
  };
58
67
 
59
68
  /** The directory each whole preset reads, for checking `topics` against it. */
package/src/rules.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ConfigUnavailable,
3
+ type Override,
3
4
  type Rule,
4
5
  type RuleId,
5
6
  type RuleSource,
@@ -26,12 +27,14 @@ export interface RuleEntry {
26
27
 
27
28
  /**
28
29
  * A rule's id as output shows it: a preset's rule with the preset's name
29
- * first, as in effect/basics/external-calls-are-resilient, so a report that
30
+ * first, as in effect/basics/gen-for-sequencing, so a report that
30
31
  * mixes presets with the project's own rules says where each came from. The
31
32
  * project's own rules show their `.adhere/` ids as they are.
32
33
  */
33
- export const shownId = (entry: { readonly id: RuleId; readonly preset?: string }): string =>
34
- entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`;
34
+ export const shownId = (entry: {
35
+ readonly id: RuleId;
36
+ readonly preset?: string | undefined;
37
+ }): string => (entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`);
35
38
 
36
39
  export type RuleSet = ReadonlyArray<RuleEntry>;
37
40
 
@@ -170,6 +173,36 @@ export const materializeRules = Effect.fn("materializeRules")(function* (
170
173
  export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: string) =>
171
174
  Object.entries(rules).map(([id, rule]): RuleEntry => ({ id, rule, scope: root }));
172
175
 
176
+ /**
177
+ * The rules as a config's overrides leave them, each found by the id a report
178
+ * names it with: a rule set `off` gone, and any other with the level and
179
+ * threshold its override gives, over its own.
180
+ */
181
+ export const withOverrides = (
182
+ entries: RuleSet,
183
+ overrides: Readonly<Record<string, Override>>,
184
+ ): RuleSet =>
185
+ entries.flatMap((entry) => {
186
+ const override = overrides[shownId(entry)];
187
+ if (override === undefined) return [entry];
188
+ const { level, threshold }: Exclude<Override, string> =
189
+ typeof override === "string" ? { level: override } : override;
190
+ if (level === "off") return [];
191
+ const rule: Rule = {
192
+ ...entry.rule,
193
+ ...(level === undefined ? {} : { level }),
194
+ ...(threshold === undefined ? {} : { threshold }),
195
+ };
196
+ return [{ ...entry, rule }];
197
+ });
198
+
199
+ /**
200
+ * Whether a rule judges a file: a test file only when the rule says `tests`,
201
+ * and any other file unless it says `tests: only`.
202
+ */
203
+ export const judgesFile = (rule: Rule, test: boolean): boolean =>
204
+ test ? rule.tests !== undefined : rule.tests !== "only";
205
+
173
206
  /**
174
207
  * The flat rule map for one source file. Root rules apply everywhere. When a
175
208
  * nested `.adhere/` defines the same id, the deepest matching scope wins.
@@ -1,23 +1,25 @@
1
1
  import {
2
2
  ADHERE_DIRECTORY,
3
- type AdhereConfig as Decoded,
3
+ AdhereConfig as AdhereConfigSchema,
4
4
  CONFIG_FILES,
5
5
  ConfigUnavailable,
6
6
  decodeConfig,
7
7
  type Loaded,
8
- type Overrides,
8
+ type Flags,
9
9
  type Preset,
10
10
  presetsOf,
11
11
  type ResolvedConfig,
12
12
  resolveConfig,
13
13
  } from "#config.ts";
14
- import { type PresetName, presetOf, wholePresetOf } from "#presets.ts";
14
+ import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
15
15
  import {
16
16
  applicableRules,
17
17
  globalRuleSet,
18
18
  loadAdhereRuleSet,
19
19
  materializeRules,
20
20
  type RuleSet,
21
+ shownId,
22
+ withOverrides,
21
23
  } from "#rules.ts";
22
24
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
23
25
 
@@ -50,13 +52,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
50
52
  return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
51
53
  });
52
54
 
55
+ /** Every rule of every built-in preset, by the id a report names it with. */
56
+ const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
57
+ const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
58
+ Effect.map(materializeRules(preset.rules, base), (rules) =>
59
+ Object.keys(rules).map((id) => `${name}/${id}`),
60
+ ),
61
+ );
62
+ return new Set(loaded.flat());
63
+ });
64
+
53
65
  /**
54
66
  * The config file in the working directory (one of `CONFIG_FILES`) with the
55
- * command-line overrides applied. The file is optional when `.adhere/` holds
67
+ * command-line flags applied. The file is optional when `.adhere/` holds
56
68
  * rules or a preset is named. Rule directories resolve against the working
57
69
  * directory.
58
70
  */
59
- export const AdhereConfigLive = (overrides: Overrides) =>
71
+ export const AdhereConfigLive = (flags: Flags) =>
60
72
  Layer.effect(AdhereConfig)(
61
73
  Effect.gen(function* () {
62
74
  const path = yield* Path.Path;
@@ -73,8 +85,8 @@ export const AdhereConfigLive = (overrides: Overrides) =>
73
85
  });
74
86
  }
75
87
  const configFile = found[0];
76
- const hasPreset = overrides.presets !== undefined && overrides.presets.length > 0;
77
- const config: Decoded =
88
+ const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
89
+ const config: typeof AdhereConfigSchema.Type =
78
90
  configFile === undefined
79
91
  ? yield* decodeConfig({})
80
92
  : yield* loadConfigFile(path.join(cwd, configFile));
@@ -90,10 +102,10 @@ export const AdhereConfigLive = (overrides: Overrides) =>
90
102
  ? discoveredEntries
91
103
  : globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
92
104
  const rules = applicableRules(cwd, projectEntries);
93
- const registry = yield* Effect.forEach(presetsOf(config, overrides), (name) =>
105
+ const registry = yield* Effect.forEach(presetsOf(config, flags), (name) =>
94
106
  loadPreset(name, cwd),
95
107
  ).pipe(Effect.map(Record.fromEntries));
96
- const resolved = resolveConfig({ ...config, rules }, overrides, registry);
108
+ const resolved = resolveConfig({ ...config, rules }, flags, registry);
97
109
  const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
98
110
  globalRuleSet(preset.rules, cwd).map((entry) => ({
99
111
  ...entry,
@@ -103,9 +115,20 @@ export const AdhereConfigLive = (overrides: Overrides) =>
103
115
  yield* Effect.logDebug(
104
116
  `config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
105
117
  );
106
- return AdhereConfig.of({
107
- ...resolved,
108
- scopedRules: [...presetEntries, ...projectEntries],
109
- });
118
+ const entries = [...presetEntries, ...projectEntries];
119
+ const overrides = config.overrides ?? {};
120
+ const loaded = new Set(entries.map(shownId));
121
+ const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
122
+ if (elsewhere.length > 0) {
123
+ // A rule of a preset this run leaves out may be overridden; one no rule has is a typo.
124
+ const every = yield* everyPresetRule(cwd);
125
+ const unknown = elsewhere.filter((id) => !every.has(id));
126
+ if (unknown.length > 0) {
127
+ return yield* ConfigUnavailable.make({
128
+ message: `overrides names ${unknown.join(", ")}, which is no preset or project rule. A preset's rule is named with its preset first, as in alchemy/providers/idempotent-delete.`,
129
+ });
130
+ }
131
+ }
132
+ return AdhereConfig.of({ ...resolved, scopedRules: withOverrides(entries, overrides) });
110
133
  }),
111
134
  );
@@ -1,11 +1,30 @@
1
1
  import { CACHE_DIRECTORY } from "#config.ts";
2
2
  import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Record, Schema } from "effect";
3
3
 
4
+ /** Jev's probability for each of a rule's `appliesTo` and `excludeIf` matchers, when it has them. */
5
+ const MatcherFields = {
6
+ appliesTo: Schema.optionalKey(Schema.Array(Schema.Finite)),
7
+ excludeIf: Schema.optionalKey(Schema.Array(Schema.Finite)),
8
+ };
9
+
10
+ /** Lines of a file, first to last. */
11
+ const Lines = Schema.Struct({ first: Schema.Finite, last: Schema.Finite });
12
+ /** The lines of the section a finding is in, once located. */
13
+ const Section = Schema.optionalKey(Lines);
14
+ /** The lines in its section the code that breaks the rule starts and ends on, when Jev named them. */
15
+ const Located = Schema.optionalKey(Lines);
16
+
17
+ /** Jev's probability, asked with the section, that the file shows enough to decide the rule. */
18
+ const Sufficiency = Schema.optionalKey(Schema.Finite);
19
+
4
20
  /** A rule's judgment as a run holds it: Jev's answer, with the fingerprint of the rule's text. */
5
21
  export const Judgment = Schema.Struct({
6
22
  fingerprint: Schema.String,
7
23
  probability: Schema.Finite,
8
- line: Schema.optionalKey(Schema.Finite),
24
+ section: Section,
25
+ lines: Located,
26
+ sufficiency: Sufficiency,
27
+ ...MatcherFields,
9
28
  });
10
29
  export interface Judgment extends Schema.Schema.Type<typeof Judgment> {}
11
30
 
@@ -21,10 +40,17 @@ export const CacheEntry = Schema.Struct({
21
40
  });
22
41
  export interface CacheEntry extends Schema.Schema.Type<typeof CacheEntry> {}
23
42
 
24
- /** Jev's answer about some code for one rule text: the probability, and the line once located. */
43
+ /**
44
+ * Jev's answer about some code for one rule text: the probability, the
45
+ * section, lines, and sufficiency once located, and its matchers' scores. An answer from before
46
+ * sections held a line instead, under a fingerprint no rule has now.
47
+ */
25
48
  export const Answer = Schema.Struct({
26
49
  probability: Schema.Finite,
27
- line: Schema.optionalKey(Schema.Finite),
50
+ section: Section,
51
+ lines: Located,
52
+ sufficiency: Sufficiency,
53
+ ...MatcherFields,
28
54
  });
29
55
  export interface Answer extends Schema.Schema.Type<typeof Answer> {}
30
56
 
@@ -98,13 +124,13 @@ const sorted = <A>(record: Readonly<Record<string, A>>): Record<string, A> =>
98
124
 
99
125
  const textOf = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
100
126
 
101
- /** One answer per rule text: one that has its line over one that has not, otherwise the first. */
127
+ /** One answer per rule text: one that has its section over one that has not, otherwise the first. */
102
128
  const unionOf = (entries: ReadonlyArray<Answers>): Answers => {
103
129
  const union: Record<string, Answer> = {};
104
130
  for (const answers of entries) {
105
131
  for (const [fingerprint, answer] of Object.entries(answers)) {
106
132
  const held = union[fingerprint];
107
- if (held === undefined || (held.line === undefined && answer.line !== undefined)) {
133
+ if (held === undefined || (held.section === undefined && answer.section !== undefined)) {
108
134
  union[fingerprint] = answer;
109
135
  }
110
136
  }
@@ -1,10 +1,11 @@
1
1
  import type { RuleId } from "#config.ts";
2
+ import { sectionsOf } from "#excerpt.ts";
2
3
  import { AdhereConfig } from "#services/AdhereConfig.ts";
3
4
  import { Credentials } from "#services/Credentials.ts";
4
5
  import {
5
- blockBody,
6
6
  type Body,
7
7
  type ComparedRule,
8
+ edgeKey,
8
9
  conflictBody,
9
10
  contradictBody,
10
11
  Jev,
@@ -16,15 +17,27 @@ import {
16
17
  type Lines,
17
18
  linterKey,
18
19
  locateBody,
20
+ matcherKey,
21
+ type MatcherScores,
19
22
  namedPairs,
20
- needsBlocks,
21
23
  type Pair,
22
24
  pairProbability,
23
25
  requestsOf,
24
26
  type Rules,
27
+ sufficiencyKey,
25
28
  tokensOf,
26
29
  } from "#services/Jev.ts";
27
- import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
30
+ import {
31
+ type Cause,
32
+ Duration,
33
+ Effect,
34
+ Layer,
35
+ Option,
36
+ Record,
37
+ Result,
38
+ Schedule,
39
+ Schema,
40
+ } from "effect";
28
41
  import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
29
42
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
30
43
  import { RateLimiter } from "effect/unstable/persistence";
@@ -37,6 +50,16 @@ const NoulAnswers = Schema.Struct({
37
50
  const ChoiceAnswers = Schema.Struct({
38
51
  answers: Schema.Record(Schema.String, Schema.Struct({ choice: Schema.String })),
39
52
  });
53
+ /** A locate request's answers: a choice per rule, and a noul per sufficiency question. */
54
+ const LocateAnswers = Schema.Struct({
55
+ answers: Schema.Record(
56
+ Schema.String,
57
+ Schema.Struct({
58
+ choice: Schema.optionalKey(Schema.String),
59
+ noul: Schema.optionalKey(Schema.Finite),
60
+ }),
61
+ ),
62
+ });
40
63
 
41
64
  const refused = (message: string) => JevUnavailable.make({ message });
42
65
 
@@ -219,22 +242,64 @@ export const JevLive = Layer.effect(Jev)(
219
242
  return answer === undefined ? [] : [[id, answer.noul] as const];
220
243
  }),
221
244
  );
245
+ // A matcher Jev left unanswered counts as a no.
246
+ const scored = (
247
+ kind: keyof MatcherScores,
248
+ id: RuleId,
249
+ matchers: ReadonlyArray<string> = [],
250
+ ) => matchers.map((_, index) => answers[matcherKey(kind, id, index)]?.noul ?? 0);
251
+ const matchers: Record<RuleId, MatcherScores> = Record.fromEntries(
252
+ Object.entries(rules).flatMap(([id, rule]) =>
253
+ rule.appliesTo === undefined && rule.excludeIf === undefined
254
+ ? []
255
+ : [
256
+ [
257
+ id,
258
+ {
259
+ appliesTo: scored("appliesTo", id, rule.appliesTo),
260
+ excludeIf: scored("excludeIf", id, rule.excludeIf),
261
+ },
262
+ ] as const,
263
+ ],
264
+ ),
265
+ );
222
266
  return {
223
267
  probabilities: answered(Object.keys(rules), (id) => id),
224
268
  linter: answered(sampled, linterKey),
269
+ ...(Record.isEmptyRecord(matchers) ? {} : { matchers }),
225
270
  } satisfies Judged;
226
271
  });
227
272
 
228
- const chosen = (answers: Readonly<Record<string, { choice: string }>>) =>
229
- Record.map(answers, (answer) => Number(answer.choice));
230
-
231
273
  const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
232
- const blocks = needsBlocks(lines)
233
- ? chosen(yield* answersTo("blocks", blockBody(config.model, lines, rules), ChoiceAnswers))
234
- : undefined;
235
- return chosen(
236
- yield* answersTo("locate", locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
274
+ const sections = sectionsOf(lines);
275
+ const answers = yield* answersTo(
276
+ "locate",
277
+ locateBody(config.model, lines, rules),
278
+ LocateAnswers,
237
279
  );
280
+ // A file of one section has every finding in it. An answer that names no section,
281
+ // or none at all, which Jev should not give, leaves the rule unlocated; lines outside
282
+ // the section leave it without lines.
283
+ return Record.filterMap(rules, (_, id) => {
284
+ const chosen = sections.length === 1 ? 1 : Number(answers[id]?.choice);
285
+ const section = sections[chosen - 1];
286
+ const sufficiency = answers[sufficiencyKey(id)]?.noul;
287
+ if (section === undefined || sufficiency === undefined) return Result.failVoid;
288
+ const [start, end] = (["start", "end"] as const).map((edge) =>
289
+ Number(answers[edgeKey(edge, id, chosen)]?.choice),
290
+ );
291
+ const within = (line: number | undefined): line is number =>
292
+ line !== undefined && section.first <= line && line <= section.last;
293
+ return Result.succeed(
294
+ within(start) && within(end)
295
+ ? {
296
+ section,
297
+ sufficiency,
298
+ lines: { first: Math.min(start, end), last: Math.max(start, end) },
299
+ }
300
+ : { section, sufficiency },
301
+ );
302
+ });
238
303
  });
239
304
 
240
305
  const conflicts = Effect.fn("Jev.conflicts")(function* (