@drkmttr/adhere 0.9.5 → 0.10.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 (83) hide show
  1. package/README.md +245 -65
  2. package/package.json +6 -6
  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/parameterized-sql.md +6 -2
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  11. package/presets/alchemy/data/single-migration-owner.md +6 -2
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  13. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  15. package/presets/alchemy/durable/stable-step-names.md +6 -2
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  18. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  19. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  25. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  26. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
  36. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  37. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  39. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  40. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  41. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  42. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  43. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  46. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  47. package/presets/effect/basics/gen-for-sequencing.md +6 -2
  48. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  49. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  50. package/presets/effect/config/secrets-are-redacted.md +6 -2
  51. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  52. package/presets/effect/config/validate-with-schema.md +6 -2
  53. package/presets/effect/data/brand-meaningful-primitives.md +6 -2
  54. package/presets/effect/data/variants-are-tagged-unions.md +6 -2
  55. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  56. package/presets/effect/errors/defects-for-bugs.md +6 -2
  57. package/presets/effect/errors/wrap-external-errors.md +6 -2
  58. package/presets/effect/services/dependencies-through-layers.md +6 -2
  59. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  60. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  61. package/presets/effect/services/no-mutable-state.md +6 -2
  62. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  63. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  64. package/presets/effect/testing/test-clock-for-time.md +7 -2
  65. package/skills/adhere/SKILL.md +40 -12
  66. package/skills/adhere-fix/SKILL.md +174 -0
  67. package/src/cli.ts +51 -14
  68. package/src/comments.ts +36 -0
  69. package/src/config.ts +98 -14
  70. package/src/contradictions.ts +10 -2
  71. package/src/excerpt.ts +11 -1
  72. package/src/index.ts +1 -1
  73. package/src/init.ts +119 -12
  74. package/src/main.ts +0 -1
  75. package/src/markdown.ts +77 -14
  76. package/src/models/Audit.ts +2 -0
  77. package/src/presets.ts +6 -3
  78. package/src/rules.ts +35 -2
  79. package/src/services/AdhereConfig.ts +36 -13
  80. package/src/services/SourceWalker.ts +32 -10
  81. package/src/suppress.ts +113 -0
  82. package/src/workflows/audit.ts +99 -17
  83. package/src/workflows/format.ts +20 -13
package/src/init.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { access, mkdir, writeFile } from "node:fs/promises";
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { dirname, join, relative } from "node:path";
3
4
  import { CONFIG_FILES } from "#config.ts";
4
5
  import { Effect } from "effect";
@@ -6,17 +7,45 @@ import { Effect } from "effect";
6
7
  export interface InitResult {
7
8
  readonly created: ReadonlyArray<string>;
8
9
  readonly skipped: ReadonlyArray<string>;
10
+ /** The package manager that added adhere, or why none did. */
11
+ readonly install: Install;
9
12
  }
10
13
 
14
+ export type Install =
15
+ | { readonly status: "installed"; readonly packageManager: PackageManager }
16
+ | { readonly status: "listed" }
17
+ | { readonly status: "no-package-json" };
18
+
11
19
  export interface InitOptions {
12
20
  readonly force?: boolean;
13
21
  }
14
22
 
15
- const CONFIG = `import type { Config } from "@drkmttr/adhere";
23
+ // adhere-ignore-file rules/import-written-files -- src/index.ts exports initProject, so a Bun text import here breaks loading the package on Node and in Vitest (checked 2026-09-26), and the compiled executable has no files beside it to read.
24
+ const CONFIG = `import { defineConfig } from "@drkmttr/adhere";
16
25
 
17
- export default {
26
+ export default defineConfig({
18
27
  threshold: 0.8,
19
- } satisfies Config;
28
+ });
29
+ `;
30
+
31
+ /**
32
+ * A project for the config alone. A tsconfig's globs skip dot directories, so
33
+ * without it the editor opens `.adhere/config.ts` outside any project, where
34
+ * it cannot resolve `@drkmttr/adhere`, whose types only `bundler`, `node16`,
35
+ * and `nodenext` resolution reach.
36
+ */
37
+ const TSCONFIG = `{
38
+ "compilerOptions": {
39
+ "target": "esnext",
40
+ "module": "esnext",
41
+ "moduleResolution": "bundler",
42
+ "strict": true,
43
+ "noEmit": true,
44
+ "skipLibCheck": true,
45
+ "allowImportingTsExtensions": true
46
+ },
47
+ "include": ["config.ts"]
48
+ }
20
49
  `;
21
50
 
22
51
  const SMALL_FILES = `---
@@ -24,16 +53,20 @@ description: A file should be small and focused, with one primary responsibility
24
53
  threshold: 0.8
25
54
  ---
26
55
 
27
- A guideline, so its description and its code say should and should not. A
56
+ A guideline, so its description and its headings say should and should not. A
28
57
  rule that must hold says must and never instead, as name-domain-actions does.
29
58
  Use this rule as a template for repository-specific conventions.
30
59
 
31
- \`\`\`ts should
60
+ ## Should
61
+
62
+ \`\`\`ts
32
63
  export const parsePort = (value: string) =>
33
64
  Port.make(Number.parseInt(value, 10));
34
65
  \`\`\`
35
66
 
36
- \`\`\`ts should not
67
+ ## Should not
68
+
69
+ \`\`\`ts
37
70
  export const parsePort = (value: string) => Port.make(Number.parseInt(value, 10));
38
71
  export const sendWelcomeEmail = (to: Email) => Mailer.send(to, welcomeTemplate);
39
72
  export const renderInvoice = (invoice: Invoice) => Html.table(invoice.lines);
@@ -45,17 +78,21 @@ description: A function must be named after the domain action it performs, never
45
78
  threshold: 0.8
46
79
  ---
47
80
 
48
- \`\`\`ts must
81
+ ## Must
82
+
83
+ \`\`\`ts
49
84
  export const loadCustomerProfile = (customerId: CustomerId) =>
50
85
  Effect.gen(function* () {
51
86
  return yield* CustomerStore.find(customerId);
52
87
  });
53
88
  \`\`\`
54
89
 
55
- A fence tagged \`never\` shows what a violation looks like. It is optional, and
56
- a rule can have one without the block above.
90
+ ## Never
57
91
 
58
- \`\`\`ts never
92
+ The code under Never shows what a violation looks like. It is optional, and a
93
+ rule can have it without the code under Must.
94
+
95
+ \`\`\`ts
59
96
  export const handle = (id: string) =>
60
97
  Effect.gen(function* () {
61
98
  return yield* CustomerStore.find(id);
@@ -65,8 +102,12 @@ export const handle = (id: string) =>
65
102
 
66
103
  const CONFIG_PATH = CONFIG_FILES[0];
67
104
 
105
+ /** The config's own project, which only a config at `.adhere/config.ts` needs. */
106
+ const CONFIG_PROJECT = ".adhere/tsconfig.json";
107
+
68
108
  const files = [
69
109
  [CONFIG_PATH, CONFIG],
110
+ [CONFIG_PROJECT, TSCONFIG],
70
111
  [".adhere/style/prefer-small-files.md", SMALL_FILES],
71
112
  [".adhere/style/name-domain-actions.md", NAME_EFFECTS],
72
113
  ] as const;
@@ -102,6 +143,69 @@ const writeScaffoldFile = (root: string, path: string, contents: string, options
102
143
  ),
103
144
  });
104
145
 
146
+ const PACKAGE = "@drkmttr/adhere";
147
+
148
+ type PackageManager = "bun" | "pnpm" | "yarn" | "npm";
149
+
150
+ /** The first lockfile at the root names the package manager; npm when there is none. */
151
+ const lockfiles: ReadonlyArray<readonly [string, PackageManager]> = [
152
+ ["bun.lock", "bun"],
153
+ ["bun.lockb", "bun"],
154
+ ["pnpm-lock.yaml", "pnpm"],
155
+ ["yarn.lock", "yarn"],
156
+ ["package-lock.json", "npm"],
157
+ ];
158
+
159
+ const packageManagerAt = async (root: string): Promise<PackageManager> => {
160
+ for (const [file, manager] of lockfiles) {
161
+ if (await exists(join(root, file))) return manager;
162
+ }
163
+ return "npm";
164
+ };
165
+
166
+ /**
167
+ * pnpm refuses to add to a workspace root without a flag. `-w` fails outside a
168
+ * workspace, so the first one, which works in both, stands in for it. pnpm 11
169
+ * also fails on msgpackr-extract's unapproved build script. It comes with
170
+ * effect, and adhere never calls msgpackr, so the second flag lets pnpm skip
171
+ * the build with a warning.
172
+ */
173
+ const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
174
+ bun: [],
175
+ pnpm: ["--ignore-workspace-root-check", "--config.strict-dep-builds=false"],
176
+ yarn: [],
177
+ npm: [],
178
+ };
179
+
180
+ /** Adds adhere to devDependencies, so the config's `import type` resolves. */
181
+ const installAdhere = (root: string) =>
182
+ Effect.tryPromise({
183
+ try: async (): Promise<Install> => {
184
+ const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
185
+ if (manifest === undefined) return { status: "no-package-json" };
186
+ const { dependencies, devDependencies } = JSON.parse(manifest);
187
+ if (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined) {
188
+ return { status: "listed" };
189
+ }
190
+ const packageManager = await packageManagerAt(root);
191
+ await new Promise<void>((resolve, reject) => {
192
+ spawn(packageManager, ["add", "-D", PACKAGE, ...addFlags[packageManager]], {
193
+ cwd: root,
194
+ stdio: "inherit",
195
+ })
196
+ .on("error", reject)
197
+ .on("exit", (code) =>
198
+ code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
199
+ );
200
+ });
201
+ return { status: "installed", packageManager };
202
+ },
203
+ catch: (cause) =>
204
+ new Error(
205
+ `Could not add ${PACKAGE} to package.json: ${cause instanceof Error ? cause.message : String(cause)}`,
206
+ ),
207
+ });
208
+
105
209
  export const initProject = (
106
210
  root: string,
107
211
  options: InitOptions = {},
@@ -115,6 +219,8 @@ export const initProject = (
115
219
  skipped.push(existing);
116
220
  continue;
117
221
  }
222
+ // A config at another path is in the project's own tsconfig, or not ours to place.
223
+ if (path === CONFIG_PROJECT && existing !== undefined) continue;
118
224
  const status = yield* writeScaffoldFile(root, path, contents, options);
119
225
  if (status === "created") {
120
226
  created.push(relative(root, join(root, path)));
@@ -122,5 +228,6 @@ export const initProject = (
122
228
  skipped.push(relative(root, join(root, path)));
123
229
  }
124
230
  }
125
- return { created, skipped };
231
+ const install = yield* installAdhere(root);
232
+ return { created, skipped, install };
126
233
  });
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,40 @@ 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.
26
35
  */
27
36
  const FrontMatter = Schema.Struct({
28
37
  description: Schema.String,
29
38
  threshold: Schema.optionalKey(Schema.FiniteFromString),
39
+ tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
40
+ level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
30
41
  });
31
42
 
32
43
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
33
44
  const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
45
+ /** An ATX heading: its level as `#`s, then its text, without a closing run of `#`s. */
46
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/gm;
34
47
 
35
48
  const unquote = (value: string): string => {
36
49
  const trimmed = value.trim();
@@ -54,11 +67,17 @@ const parseFrontMatter = (block: string): Record<string, string> => {
54
67
  type Word = Example["word"];
55
68
 
56
69
  interface Fence {
57
- /** The word the info string names the code with; none for an untagged fence. */
70
+ /** The word its info string, or else its heading, names the code with; none for untagged code. */
58
71
  readonly word: Word | undefined;
59
72
  readonly code: string;
60
73
  }
61
74
 
75
+ interface Heading {
76
+ readonly level: number;
77
+ /** The word the heading names its section's code with; none for any other heading. */
78
+ readonly word: Word | undefined;
79
+ }
80
+
62
81
  /** `must not`, like `never` and the older `avoid`, names code never to write. */
63
82
  const wordOf = (info: string): Word | undefined => {
64
83
  const words = info.trim().toLowerCase().split(/\s+/);
@@ -69,11 +88,55 @@ const wordOf = (info: string): Word | undefined => {
69
88
  return undefined;
70
89
  };
71
90
 
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
- }));
91
+ const HEADING_WORDS: ReadonlySet<string> = new Set([
92
+ "must",
93
+ "must not",
94
+ "never",
95
+ "avoid",
96
+ "should",
97
+ "should not",
98
+ ]);
99
+
100
+ /**
101
+ * A heading names a word only when it is the word alone, in any case and with
102
+ * or without a colon: `## Never` does, `## Why never` does not.
103
+ */
104
+ const headingWordOf = (text: string): Word | undefined => {
105
+ const phrase = text.trim().replace(/:$/, "").trim().toLowerCase().split(/\s+/).join(" ");
106
+ return HEADING_WORDS.has(phrase) ? wordOf(phrase) : undefined;
107
+ };
108
+
109
+ /**
110
+ * The headings open after `text`, outermost first, given those open before
111
+ * it. A heading closes the sections at its level and deeper, so a deeper
112
+ * heading stays inside the section above it.
113
+ */
114
+ const openAfter = (open: ReadonlyArray<Heading>, text: string): ReadonlyArray<Heading> =>
115
+ Array.from(text.matchAll(HEADING)).reduce<ReadonlyArray<Heading>>((headings, match) => {
116
+ const level = (match[1] ?? "#").length;
117
+ return [
118
+ ...headings.filter((heading) => heading.level < level),
119
+ { level, word: headingWordOf(match[2] ?? "") },
120
+ ];
121
+ }, open);
122
+
123
+ /**
124
+ * Each fence, under the word its info string names or, without one, the word
125
+ * of the innermost heading over it that names one.
126
+ */
127
+ const fencesOf = (body: string): ReadonlyArray<Fence> => {
128
+ const fences: Array<Fence> = [];
129
+ let open: ReadonlyArray<Heading> = [];
130
+ let read = 0;
131
+ for (const match of body.matchAll(FENCE)) {
132
+ // Only headings between fences count: a `#` line inside one is code.
133
+ open = openAfter(open, body.slice(read, match.index));
134
+ read = match.index + match[0].length;
135
+ const section = open.findLast((heading) => heading.word !== undefined)?.word;
136
+ fences.push({ word: wordOf(match[2] ?? "") ?? section, code: (match[3] ?? "").trim() });
137
+ }
138
+ return fences;
139
+ };
77
140
 
78
141
  /** An empty block counts as no block. */
79
142
  const nonEmpty = (code: string | undefined): string | undefined =>
@@ -134,7 +197,7 @@ export const parseRuleMarkdown = (
134
197
  }
135
198
  if (!requirement && !guideline) {
136
199
  return yield* ConfigUnavailable.make({
137
- message: `${file}: the body needs code, in a fence tagged must, never, should, or should not, or untagged`,
200
+ message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
138
201
  });
139
202
  }
140
203
  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)
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,
@@ -30,8 +31,10 @@ export interface RuleEntry {
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,9 +1,10 @@
1
1
  import { matchesGlob } from "node:path";
2
2
  import { Context, Effect, FileSystem, Layer, Path } from "effect";
3
- import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
3
+ import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES, TEST_DIRECTORIES } from "#config.ts";
4
4
  import type { ScannedFile } from "#models/Audit.ts";
5
5
  import { WalkUnavailable } from "#models/Audit.ts";
6
6
  import { walkFiles } from "#walk.ts";
7
+ import { AdhereConfig } from "#services/AdhereConfig.ts";
7
8
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
8
9
 
9
10
  /** Walks the repository's TypeScript source, skipping what is not ours to read. */
@@ -11,9 +12,9 @@ export class SourceWalker extends Context.Service<
11
12
  SourceWalker,
12
13
  {
13
14
  /**
14
- * Every TypeScript file under the audit's roots, in directory order. A
15
- * file that cannot be read refuses the walk: a silent gap would report
16
- * a clean audit that was never complete.
15
+ * Every TypeScript file under the audit's roots, in directory order, test
16
+ * files marked. A file that cannot be read refuses the walk: a silent gap
17
+ * would report a clean audit that was never complete.
17
18
  */
18
19
  readonly files: Effect.Effect<ReadonlyArray<ScannedFile>, WalkUnavailable>;
19
20
  }
@@ -50,13 +51,26 @@ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>):
50
51
  return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
51
52
  };
52
53
 
54
+ /**
55
+ * Whether a path, relative to the working directory, is a test: a `.test.ts`
56
+ * or `.spec.ts` file, or any file under a test directory such as `test/`.
57
+ */
58
+ export const isTestFile = (relative: string): boolean => {
59
+ const segments = relative.split(/[\\/]/);
60
+ const name = segments.at(-1) ?? "";
61
+ return (
62
+ name.endsWith(".test.ts") ||
63
+ name.endsWith(".spec.ts") ||
64
+ segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
65
+ );
66
+ };
67
+
53
68
  const isInside = (file: string, root: string): boolean =>
54
69
  file === root || file.startsWith(`${root}/`);
55
70
 
56
71
  const isScannable = (file: string, self: string): boolean =>
57
72
  file.endsWith(".ts") &&
58
73
  !file.endsWith(".d.ts") &&
59
- !file.endsWith(".test.ts") &&
60
74
  !file.endsWith("/adhere.config.ts") &&
61
75
  !file.endsWith("/.adhere.config.ts") &&
62
76
  // The audit's own detector patterns are data, not violations of themselves.
@@ -169,8 +183,10 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
169
183
 
170
184
  /**
171
185
  * The real walker: the roots walked a directory at a time, skipped trees
172
- * left unread, each file read and split into lines. Tests substitute their
173
- * own tree.
186
+ * left unread, each file read and split into lines. The run's filter and the
187
+ * config's `exclude` leave out what they match. Test files are read, and
188
+ * marked, even when no rule judges them, so a prune keeps what other runs
189
+ * know of them. Tests substitute their own tree.
174
190
  * The scan root is the current working directory, so the audit reads the
175
191
  * repository it is run in.
176
192
  */
@@ -179,9 +195,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
179
195
  Effect.gen(function* () {
180
196
  const fs = yield* FileSystem.FileSystem;
181
197
  const path = yield* Path.Path;
198
+ const config = yield* AdhereConfig;
182
199
  // No segments: Path resolves against the working directory.
183
200
  const root = path.resolve();
184
201
  const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
202
+ const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
185
203
  const walk = Effect.fn("SourceWalker.files")(function* () {
186
204
  const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
187
205
  const trees = yield* clearingStatus(
@@ -193,12 +211,16 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
193
211
  dir === "." ? "the working directory" : `${dir}/`,
194
212
  self,
195
213
  (relative) =>
196
- // Filter patterns are relative to the working directory, not the tree read.
197
- passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
214
+ // Patterns are relative to the working directory, not the tree read.
215
+ passesFilter(dir === "." ? relative : `${dir}/${relative}`, patterns),
198
216
  ).pipe(Effect.mapError(refused)),
199
217
  ),
200
218
  );
201
- return trees.flat();
219
+ return trees
220
+ .flat()
221
+ .map((file) =>
222
+ isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
223
+ );
202
224
  });
203
225
  return SourceWalker.of({ files: walk() });
204
226
  }),