@drkmttr/adhere 0.11.0 → 0.13.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 (102) hide show
  1. package/README.md +184 -37
  2. package/package.json +9 -9
  3. package/presets/effect/basics/{instrument-with-pipe.md → instrument-with-pipe/RULE.md} +1 -1
  4. package/presets/effect/config/{business-logic-depends-on-config-service.md → business-logic-depends-on-config-service/RULE.md} +3 -3
  5. package/presets/effect/config/{secrets-are-redacted.md → secrets-are-redacted/RULE.md} +3 -3
  6. package/presets/effect/config/{validate-with-schema.md → validate-with-schema/RULE.md} +4 -4
  7. package/skills/adhere/SKILL.md +206 -118
  8. package/skills/adhere-fix/SKILL.md +9 -8
  9. package/skills/adhere-setup/SKILL.md +244 -0
  10. package/src/cli.ts +114 -25
  11. package/src/config.ts +50 -3
  12. package/src/index.ts +2 -0
  13. package/src/init.ts +193 -27
  14. package/src/install.ts +158 -0
  15. package/src/main.ts +1 -1
  16. package/src/rules.ts +135 -56
  17. package/src/services/AdhereConfig.ts +2 -2
  18. package/src/services/Credentials.ts +4 -4
  19. package/src/services/Jev.http.ts +76 -10
  20. package/src/services/Jev.ts +39 -10
  21. package/src/workflows/audit.ts +23 -12
  22. package/src/workflows/format.ts +172 -35
  23. /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
  24. /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
  25. /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
  26. /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
  27. /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
  28. /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
  29. /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
  30. /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
  31. /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
  32. /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
  33. /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
  34. /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
  35. /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
  36. /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
  37. /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
  38. /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
  39. /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
  40. /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
  41. /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
  42. /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
  43. /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
  44. /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
  45. /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
  46. /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
  47. /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
  48. /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
  49. /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
  50. /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
  51. /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
  52. /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
  53. /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
  54. /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
  55. /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
  56. /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
  57. /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
  58. /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
  59. /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
  60. /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
  61. /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
  62. /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
  63. /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
  64. /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
  65. /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
  66. /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
  67. /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
  68. /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
  69. /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
  70. /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
  71. /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
  72. /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
  73. /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
  74. /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
  75. /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
  76. /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
  77. /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
  78. /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
  79. /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
  80. /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
  81. /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
  82. /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
  83. /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
  84. /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
  85. /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
  86. /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
  87. /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
  88. /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
  89. /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
  90. /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
  91. /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
  92. /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
  93. /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
  94. /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
  95. /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
  96. /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
  97. /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
  98. /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
  99. /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
  100. /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
  101. /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
  102. /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
package/src/rules.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ConfigUnavailable,
3
3
  type Override,
4
- type Rule,
4
+ Rule,
5
5
  type RuleId,
6
6
  type RuleSource,
7
7
  SKIPPED_DIRECTORIES,
@@ -9,7 +9,7 @@ import {
9
9
  import { parseRuleMarkdown } from "#markdown.ts";
10
10
  import { walkFiles } from "#walk.ts";
11
11
  import { clearingStatus, countOf, Status } from "#services/Status.ts";
12
- import { Effect, FileSystem, Path, Record } from "effect";
12
+ import { Effect, FileSystem, Path, Record, Schema } from "effect";
13
13
 
14
14
  const refused = (directory: string) => (problem: { readonly message: string }) =>
15
15
  ConfigUnavailable.make({
@@ -40,6 +40,10 @@ export type RuleSet = ReadonlyArray<RuleEntry>;
40
40
 
41
41
  const ADHERE_SEGMENT = ".adhere";
42
42
  const CACHE_SEGMENT = "cache";
43
+ /** Where in a `.adhere/` its rules sit, one to a directory. */
44
+ const RULES_PREFIX = "rules/";
45
+ /** The file in a rule's directory that is the rule: Markdown, or TypeScript that default-exports `defineRule({...})`. */
46
+ const RULE_FILES: ReadonlySet<string> = new Set(["RULE.md", "RULE.ts"]);
43
47
 
44
48
  const normalized = (path: string): string => path.replaceAll("\\", "/");
45
49
 
@@ -48,12 +52,69 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
48
52
  .split("/")
49
53
  .filter((segment) => segment.length > 0);
50
54
 
51
- const isMarkdown = (path: string): boolean => path.endsWith(".md");
55
+ /** A rule file, and its path in the directory it was read from. */
56
+ interface RuleFile {
57
+ readonly id: RuleId;
58
+ readonly leaf: string;
59
+ }
60
+
61
+ /**
62
+ * The rules among `leaves`, the paths in `directory`, whose rules sit under
63
+ * `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Anything else
64
+ * in a rule's directory, such as a helper its RULE.ts imports, is not a rule.
65
+ * A directory with both files refuses, and so does a Markdown file outside
66
+ * every rule's directory, but a README.md: before rule directories, it was a
67
+ * rule, and the refusal says where it goes now to keep its id.
68
+ */
69
+ const ruleFilesIn = (
70
+ directory: string,
71
+ leaves: ReadonlyArray<string>,
72
+ prefix: string,
73
+ ): Effect.Effect<ReadonlyArray<RuleFile>, ConfigUnavailable> => {
74
+ const files = leaves.flatMap((leaf): ReadonlyArray<RuleFile> => {
75
+ const segments = segmentsOf(leaf.slice(prefix.length));
76
+ return leaf.startsWith(prefix) && segments.length > 1 && RULE_FILES.has(segments.at(-1) ?? "")
77
+ ? [{ id: segments.slice(0, -1).join("/"), leaf }]
78
+ : [];
79
+ });
80
+ const both = files.find((file, index) => files.findIndex(({ id }) => id === file.id) < index);
81
+ if (both !== undefined) {
82
+ return Effect.fail(
83
+ ConfigUnavailable.make({
84
+ message: `${directory}/${prefix}${both.id}: a rule is a RULE.md or a RULE.ts, not both`,
85
+ }),
86
+ );
87
+ }
88
+ const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
89
+ const flat = leaves.filter((leaf) => {
90
+ const name = segmentsOf(leaf).at(-1) ?? "";
91
+ return (
92
+ leaf.endsWith(".md") &&
93
+ !RULE_FILES.has(name) &&
94
+ name !== "README.md" &&
95
+ !homes.some((home) => leaf.startsWith(home))
96
+ );
97
+ });
98
+ if (flat.length > 0) {
99
+ return Effect.fail(
100
+ ConfigUnavailable.make({
101
+ message: [
102
+ `Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
103
+ ...flat.map(
104
+ (leaf) =>
105
+ ` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
106
+ ),
107
+ ].join("\n"),
108
+ }),
109
+ );
110
+ }
111
+ return Effect.succeed(files);
112
+ };
52
113
 
53
114
  /**
54
115
  * Whether the rule walk reads into a directory: on the way to a `.adhere/`,
55
116
  * not a skipped tree or `.git`; inside one, anything but its cache, since a
56
- * directory there such as `.adhere/e2e/` is a rule topic.
117
+ * directory there such as `.adhere/rules/e2e/` is a rule topic.
57
118
  */
58
119
  const entersForRules = (relative: string): boolean => {
59
120
  const segments = segmentsOf(relative);
@@ -65,45 +126,30 @@ const entersForRules = (relative: string): boolean => {
65
126
  return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
66
127
  };
67
128
 
68
- const isNestedRuleFile = (relative: string): boolean => {
69
- const segments = segmentsOf(relative);
70
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
71
- return (
72
- adhere >= 0 &&
73
- segments[adhere + 1] !== CACHE_SEGMENT &&
74
- isMarkdown(relative) &&
75
- // Only the path to the `.adhere/`: `.adhere/e2e/` is a rule topic, not a skip.
76
- !segments.slice(0, adhere).some((segment) => SKIPPED_DIRECTORIES.has(segment))
77
- );
78
- };
79
-
80
- const rulePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
81
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
82
- return adhere < 0 ? [] : segments.slice(adhere + 1);
83
- };
84
-
85
- const scopePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
86
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
87
- return adhere < 0 ? [] : segments.slice(0, adhere);
88
- };
89
-
90
- const ruleIdOf = (relative: string): RuleId => {
91
- const rulePath = rulePathOf(segmentsOf(relative));
92
- const leaf = rulePath.join("/");
93
- return leaf.slice(0, -".md".length);
129
+ /**
130
+ * Each `.adhere/` the walk found, by its path from the root, with the paths
131
+ * of the files in it. Only the path to a `.adhere/` counts against skipped
132
+ * trees: `.adhere/rules/e2e/` is a rule topic, not a skip.
133
+ */
134
+ const adhereDirectoriesOf = (
135
+ relatives: ReadonlyArray<string>,
136
+ ): ReadonlyMap<string, ReadonlyArray<string>> => {
137
+ const found = new Map<string, Array<string>>();
138
+ for (const relative of relatives) {
139
+ const segments = segmentsOf(relative);
140
+ const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
141
+ if (adhere < 0 || segments.slice(0, adhere).some((name) => SKIPPED_DIRECTORIES.has(name))) {
142
+ continue;
143
+ }
144
+ const directory = segments.slice(0, adhere + 1).join("/");
145
+ found.set(directory, [...(found.get(directory) ?? []), segments.slice(adhere + 1).join("/")]);
146
+ }
147
+ return found;
94
148
  };
95
149
 
96
150
  const joinPath = (path: Path.Path, root: string, parts: ReadonlyArray<string>): string =>
97
151
  parts.reduce((current, part) => path.join(current, part), root);
98
152
 
99
- const scopedRule = (path: Path.Path, root: string, relative: string, rule: Rule): RuleEntry => {
100
- const segments = segmentsOf(relative);
101
- const scopeParts = scopePathOf(segments);
102
- const scope = scopeParts.length === 0 ? path.resolve(root) : joinPath(path, root, scopeParts);
103
- const file = joinPath(path, root, segments);
104
- return { id: ruleIdOf(relative), file, scope, rule };
105
- };
106
-
107
153
  const isInside = (file: string, scope: string): boolean =>
108
154
  file === scope || file.startsWith(`${scope}/`);
109
155
 
@@ -111,9 +157,40 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
111
157
  normalized(a.scope).length >= normalized(b.scope).length ? a : b;
112
158
 
113
159
  /**
114
- * Every `*.md` file under `directory`, validated and keyed by its path
115
- * relative to the directory without the extension: `basics/gen.md` is the
116
- * rule `basics/gen`.
160
+ * A rule written in TypeScript: the file's default export, decoded as a
161
+ * config's inline rule is, so a hook such as `appendState` comes with it.
162
+ * Like a config, the file is imported, so its code runs.
163
+ */
164
+ const importRule = Effect.fn("importRule")(function* (file: string) {
165
+ const path = yield* Path.Path;
166
+ const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
167
+ const loaded = yield* Effect.tryPromise({
168
+ try: () => import(url.href) as Promise<{ readonly default?: unknown }>,
169
+ catch: (cause) =>
170
+ ConfigUnavailable.make({
171
+ message: `Could not load ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
172
+ }),
173
+ });
174
+ return yield* Schema.decodeUnknownEffect(Rule)(loaded.default).pipe(
175
+ Effect.mapError((problem) =>
176
+ ConfigUnavailable.make({
177
+ message: `${file}: a rule file must default-export defineRule({...}): ${problem.message}`,
178
+ }),
179
+ ),
180
+ );
181
+ });
182
+
183
+ /** A rule file's rule. A file that cannot be read refuses with the directory it was read from. */
184
+ const readRule = Effect.fn("readRule")(function* (file: string, directory: string) {
185
+ if (file.endsWith(".ts")) return yield* importRule(file);
186
+ const fs = yield* FileSystem.FileSystem;
187
+ const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
188
+ return yield* parseRuleMarkdown(text, file);
189
+ });
190
+
191
+ /**
192
+ * Every rule under `directory`, validated and keyed by its directory's path
193
+ * in it: `basics/gen/RULE.md` is the rule `basics/gen`.
117
194
  */
118
195
  export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
119
196
  const fs = yield* FileSystem.FileSystem;
@@ -121,19 +198,18 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
121
198
  const entries = yield* fs
122
199
  .readDirectory(directory, { recursive: true })
123
200
  .pipe(Effect.mapError(refused(directory)));
124
- const files = entries.filter((entry) => entry.endsWith(".md")).sort();
125
- const rules = yield* Effect.forEach(files, (relative) =>
126
- Effect.gen(function* () {
127
- const file = path.join(directory, relative);
128
- const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
129
- const id: RuleId = relative.slice(0, -".md".length).split(path.sep).join("/");
130
- return [id, yield* parseRuleMarkdown(text, file)] as const;
131
- }),
201
+ const files = yield* ruleFilesIn(directory, entries.map(normalized).sort(), "");
202
+ const rules = yield* Effect.forEach(files, ({ id, leaf }) =>
203
+ Effect.map(readRule(path.join(directory, leaf), directory), (rule) => [id, rule] as const),
132
204
  );
133
205
  return Record.fromEntries(rules) as Readonly<Record<RuleId, Rule>>;
134
206
  });
135
207
 
136
- /** Every nested `.adhere/` rule under `root`, scoped to its owner directory. */
208
+ /**
209
+ * Every rule in a `.adhere/rules/` under `root`, scoped to the directory that
210
+ * holds its `.adhere/`: `packages/api/.adhere/rules/data/ports/RULE.md` is
211
+ * `data/ports` for the files under `packages/api/`.
212
+ */
137
213
  export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
138
214
  const fs = yield* FileSystem.FileSystem;
139
215
  const path = yield* Path.Path;
@@ -145,14 +221,17 @@ export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root:
145
221
  ),
146
222
  ),
147
223
  ).pipe(Effect.mapError(refused(root)));
148
- const files = entries.filter(isNestedRuleFile);
149
- return yield* Effect.forEach(files, (relative) =>
150
- Effect.gen(function* () {
151
- const file = joinPath(path, root, segmentsOf(relative));
152
- const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(root)));
153
- return scopedRule(path, root, relative, yield* parseRuleMarkdown(text, file));
224
+ const found = yield* Effect.forEach(adhereDirectoriesOf(entries), ([adhere, leaves]) =>
225
+ Effect.flatMap(ruleFilesIn(adhere, [...leaves].sort(), RULES_PREFIX), (files) => {
226
+ const owner = segmentsOf(adhere).slice(0, -1);
227
+ const scope = owner.length === 0 ? path.resolve(root) : joinPath(path, root, owner);
228
+ return Effect.forEach(files, ({ id, leaf }) => {
229
+ const file = joinPath(path, root, segmentsOf(`${adhere}/${leaf}`));
230
+ return Effect.map(readRule(file, root), (rule): RuleEntry => ({ id, file, scope, rule }));
231
+ });
154
232
  }),
155
233
  );
234
+ return found.flat();
156
235
  });
157
236
 
158
237
  /** Rules as given, or loaded from the directory a source names. */
@@ -1,5 +1,4 @@
1
1
  import {
2
- ADHERE_DIRECTORY,
3
2
  AdhereConfig as AdhereConfigSchema,
4
3
  CONFIG_FILES,
5
4
  ConfigUnavailable,
@@ -10,6 +9,7 @@ import {
10
9
  presetsOf,
11
10
  type ResolvedConfig,
12
11
  resolveConfig,
12
+ RULES_DIRECTORY,
13
13
  } from "#config.ts";
14
14
  import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
15
15
  import {
@@ -94,7 +94,7 @@ export const AdhereConfigLive = (flags: Flags) =>
94
94
  const hasProjectRules = discoveredEntries.length > 0;
95
95
  if (configFile === undefined && !hasProjectRules && !hasPreset) {
96
96
  return yield* ConfigUnavailable.make({
97
- message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no ${ADHERE_DIRECTORY}/ directory of rules, and no --preset. Pass --preset effect to use the built-in rules.`,
97
+ message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no rules in ${RULES_DIRECTORY}/, and no --preset. Pass --preset effect to use the built-in rules.`,
98
98
  });
99
99
  }
100
100
  const projectEntries: RuleSet =
@@ -46,10 +46,10 @@ export const CredentialsLive = Layer.effect(Credentials)(
46
46
  const fs = yield* FileSystem.FileSystem;
47
47
  const path = yield* Path.Path;
48
48
 
49
- const file = Config.nonEmptyString("XDG_CONFIG_HOME").pipe(
49
+ const file = Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
50
50
  Config.orElse(() =>
51
- Config.nonEmptyString("HOME").pipe(
52
- Config.orElse(() => Config.nonEmptyString("USERPROFILE")),
51
+ Config.NonEmptyString("HOME").pipe(
52
+ Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
53
53
  Config.map((home) => path.join(home, ".config")),
54
54
  ),
55
55
  ),
@@ -78,7 +78,7 @@ export const CredentialsLive = Layer.effect(Credentials)(
78
78
 
79
79
  // Read when a request needs the key, not before: a cached run needs none.
80
80
  const apiKey = Effect.fn("Credentials.apiKey")(function* () {
81
- const fromEnvironment = yield* Config.option(Config.redacted("TYPESAFE_API_KEY")).pipe(
81
+ const fromEnvironment = yield* Config.option(Config.Redacted("TYPESAFE_API_KEY")).pipe(
82
82
  Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
83
83
  );
84
84
  if (Option.isSome(fromEnvironment)) return fromEnvironment.value;
@@ -1,4 +1,4 @@
1
- import type { RuleId } from "#config.ts";
1
+ import type { AppendState, JevState, JudgedFile, RuleId } from "#config.ts";
2
2
  import { sectionsOf } from "#excerpt.ts";
3
3
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
4
  import { Credentials } from "#services/Credentials.ts";
@@ -22,6 +22,7 @@ import {
22
22
  namedPairs,
23
23
  type Pair,
24
24
  pairProbability,
25
+ requestGroups,
25
26
  requestsOf,
26
27
  type Rules,
27
28
  sufficiencyKey,
@@ -38,9 +39,9 @@ import {
38
39
  Schedule,
39
40
  Schema,
40
41
  } from "effect";
41
- import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
42
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
43
- import { RateLimiter } from "effect/unstable/persistence";
42
+ import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
43
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
44
+ import { RateLimiter } from "effect/persistence";
44
45
 
45
46
  const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
46
47
 
@@ -63,6 +64,35 @@ const LocateAnswers = Schema.Struct({
63
64
 
64
65
  const refused = (message: string) => JevUnavailable.make({ message });
65
66
 
67
+ /** Bun's API, for a rule's hook. Read off `globalThis`, as presets.ts reads it: Vitest runs this on Node. */
68
+ const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
69
+
70
+ /**
71
+ * A body as it goes out: when its rule has `appendState`, with what the hook
72
+ * returns spread over the body's state. A hook that fails refuses the run.
73
+ */
74
+ const hooked = (
75
+ body: Body & { readonly state: JevState },
76
+ group: Rules,
77
+ file: JudgedFile,
78
+ ): Effect.Effect<Body, JevUnavailable> => {
79
+ const [hook] = Object.entries(group).flatMap(([id, rule]) =>
80
+ rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
81
+ );
82
+ if (hook === undefined) return Effect.succeed(body);
83
+ const [id, appendState] = hook;
84
+ return Effect.tryPromise({
85
+ try: async () => ({
86
+ ...body,
87
+ state: { ...body.state, ...(await appendState(body.state, file, bun)) },
88
+ }),
89
+ catch: (cause) =>
90
+ refused(
91
+ `the appendState of ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
92
+ ),
93
+ });
94
+ };
95
+
66
96
  /** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
67
97
  const attemptFailure = (
68
98
  problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
@@ -224,14 +254,44 @@ export const JevLive = Layer.effect(Jev)(
224
254
  ),
225
255
  );
226
256
 
257
+ /**
258
+ * Every answer about a file's rules: a body for each group that
259
+ * `requestGroups` makes of them, each with its rule's hook run right
260
+ * before it goes out.
261
+ */
262
+ const answersAbout = <A>(
263
+ kind: string,
264
+ rules: Rules,
265
+ bodyOf: (group: Rules) => Body & { readonly state: JevState },
266
+ file: JudgedFile,
267
+ Answers: Schema.Codec<
268
+ { readonly answers: Readonly<Record<string, A>> },
269
+ unknown,
270
+ never,
271
+ never
272
+ >,
273
+ ) =>
274
+ Effect.map(
275
+ Effect.forEach(requestGroups(rules), (group) =>
276
+ Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
277
+ answersTo(kind, body, Answers),
278
+ ),
279
+ ),
280
+ (groups) =>
281
+ groups.reduce<Record<string, A>>((answers, some) => ({ ...answers, ...some }), {}),
282
+ );
283
+
227
284
  const judge = Effect.fn("Jev.judge")(function* (
228
285
  lines: Lines,
229
286
  rules: Rules,
230
- sampled: ReadonlyArray<RuleId> = [],
287
+ sampled: ReadonlyArray<RuleId>,
288
+ file: JudgedFile,
231
289
  ) {
232
- const answers = yield* answersTo(
290
+ const answers = yield* answersAbout(
233
291
  "judge",
234
- judgeBody(config.model, lines, rules, sampled),
292
+ rules,
293
+ (group) => judgeBody(config.model, lines, group, sampled),
294
+ file,
235
295
  NoulAnswers,
236
296
  );
237
297
  /** Each rule's answer, read from the question it rode under. */
@@ -270,11 +330,17 @@ export const JevLive = Layer.effect(Jev)(
270
330
  } satisfies Judged;
271
331
  });
272
332
 
273
- const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
333
+ const locate = Effect.fn("Jev.locate")(function* (
334
+ lines: Lines,
335
+ rules: Rules,
336
+ file: JudgedFile,
337
+ ) {
274
338
  const sections = sectionsOf(lines);
275
- const answers = yield* answersTo(
339
+ const answers = yield* answersAbout(
276
340
  "locate",
277
- locateBody(config.model, lines, rules),
341
+ rules,
342
+ (group) => locateBody(config.model, lines, group),
343
+ file,
278
344
  LocateAnswers,
279
345
  );
280
346
  // A file of one section has every finding in it. An answer that names no section,
@@ -1,4 +1,11 @@
1
- import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
1
+ import {
2
+ type Example,
3
+ examplesOf,
4
+ type JudgedFile,
5
+ type Rule,
6
+ type RuleId,
7
+ type Rules,
8
+ } from "#config.ts";
2
9
  import { type Range, sectionsOf } from "#excerpt.ts";
3
10
  import { Context, type Effect, Record, Schema } from "effect";
4
11
 
@@ -47,12 +54,13 @@ export class Jev extends Context.Service<
47
54
  /**
48
55
  * Each rule's probability that the file breaks it. A rule in `sampled`
49
56
  * also carries the linter check's question, whose answer comes back
50
- * under `linter`.
57
+ * under `linter`. `file` is what a rule's `appendState` gets.
51
58
  */
52
59
  readonly judge: (
53
60
  lines: Lines,
54
61
  rules: Rules,
55
- sampled?: ReadonlyArray<RuleId>,
62
+ sampled: ReadonlyArray<RuleId>,
63
+ file: JudgedFile,
56
64
  ) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
57
65
  /**
58
66
  * Per rule, the section of the file that most clearly breaks it, and
@@ -62,6 +70,7 @@ export class Jev extends Context.Service<
62
70
  readonly locate: (
63
71
  lines: Lines,
64
72
  rules: Rules,
73
+ file: JudgedFile,
65
74
  ) => Effect.Effect<Record<RuleId, Located>, JevUnavailable | JevOverflow | JevBlocked>;
66
75
  /**
67
76
  * Per rule, the partner Jev names as impossible to follow in the same code,
@@ -468,33 +477,53 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
468
477
  return requests.map(({ questions }) => ({ ...body, questions }));
469
478
  };
470
479
 
480
+ /**
481
+ * The rules as requests carry them: every rule without `appendState` in one
482
+ * body, and each rule with it in a body of its own, so that what its hook
483
+ * puts in the state only its own questions read.
484
+ */
485
+ export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
486
+ const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
487
+ return [
488
+ ...(Record.isEmptyRecord(shared) ? [] : [shared]),
489
+ ...Object.entries(rules).flatMap(([id, rule]) =>
490
+ rule.appendState === undefined ? [] : [{ [id]: rule }],
491
+ ),
492
+ ];
493
+ };
494
+
495
+ /** The judge requests for `rules` over a file: a body per group, split to fit, as `judge` sends them. */
496
+ const judgeRequestsOf = (lines: Lines, rules: Rules, sampled: ReadonlyArray<RuleId>) =>
497
+ requestGroups(rules).flatMap((group) => requestsOf(judgeBody("", lines, group, sampled)));
498
+
471
499
  /**
472
500
  * The requests judging `rules` over a file takes, and about how many input
473
- * tokens they carry, by `tokensOf`: the judge body, split to fit, as `judge`
474
- * sends it, each request with the file again.
501
+ * tokens they carry, by `tokensOf`: the judge bodies, split to fit, as
502
+ * `judge` sends them, each request with the file again. What a rule's
503
+ * `appendState` adds is not known before it runs, so it is not counted.
475
504
  */
476
505
  export const judgeLoad = (
477
506
  lines: Lines,
478
507
  rules: Rules,
479
508
  sampled: ReadonlyArray<RuleId> = [],
480
509
  ): { readonly requests: number; readonly tokens: number } => {
481
- const requests = requestsOf(judgeBody("", lines, rules, sampled));
510
+ const requests = judgeRequestsOf(lines, rules, sampled);
482
511
  return {
483
512
  requests: requests.length,
484
513
  tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
485
514
  };
486
515
  };
487
516
 
488
- /** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
517
+ /** The requests judging `rules` over a file takes: the judge bodies, split to fit, as `judge` sends them. */
489
518
  export const judgeRequests = (
490
519
  lines: Lines,
491
520
  rules: Rules,
492
521
  sampled: ReadonlyArray<RuleId> = [],
493
- ): number => requestsOf(judgeBody("", lines, rules, sampled)).length;
522
+ ): number => judgeRequestsOf(lines, rules, sampled).length;
494
523
 
495
- /** The requests locating `rules` in a file takes, split to fit as `locate` sends them. */
524
+ /** The requests locating `rules` in a file takes, a body per group split to fit, as `locate` sends them. */
496
525
  export const locateRequests = (lines: Lines, rules: Rules): number =>
497
- requestsOf(locateBody("", lines, rules)).length;
526
+ requestGroups(rules).flatMap((group) => requestsOf(locateBody("", lines, group))).length;
498
527
 
499
528
  /** A choice keeps one criterion for "none", which leaves this many for partners. */
500
529
  const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
@@ -41,12 +41,14 @@ export interface Finding {
41
41
  /** The section's code as written, comments and all, without blank lines at either end. */
42
42
  readonly excerpt: Excerpt;
43
43
  /**
44
- * Jev's probability that the file does not show enough to check the rule,
45
- * when its probability that it does is below the sufficiency threshold:
46
- * the report warns, and says how to add the context.
44
+ * Jev's probability that the file shows enough to check the rule. Below the
45
+ * sufficiency threshold, the report warns, and says how to add the context.
47
46
  */
48
- readonly insufficiency?: number;
47
+ readonly context?: number;
48
+ /** Jev's probability that the file breaks the rule. */
49
49
  readonly probability: number;
50
+ /** The rule's threshold: lint reports the finding only when its probability is above it. */
51
+ readonly threshold: number;
50
52
  /** An error fails the run; a warning does not, unless `--deny-warnings`. */
51
53
  readonly level: Level;
52
54
  }
@@ -109,12 +111,16 @@ const LAYOUT = "sections";
109
111
  * laid out, and the questions asked for the rule, which carry the rule: its
110
112
  * judge question, and its matchers' when it has any. An edited rule or
111
113
  * matcher re-judges the rule, and a question asked differently re-judges
112
- * every rule.
114
+ * every rule. So does an edited `appendState`, by its source; what the hook
115
+ * reads outside the file is not known until it runs, so a change there does not.
113
116
  */
114
117
  const fingerprintOf = (model: string, rule: Rule) => {
115
118
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
116
119
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
117
- return sha256(`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}`);
120
+ const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
121
+ return sha256(
122
+ `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
123
+ );
118
124
  };
119
125
 
120
126
  /** A section's lines as written, without the blank lines at either end. */
@@ -192,6 +198,8 @@ export interface AuditPlan {
192
198
  readonly tokens: number;
193
199
  /** The model's price per million input tokens, in dollars, when adhere knows it. */
194
200
  readonly price?: number;
201
+ /** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
202
+ readonly threshold: number;
195
203
  /** Below this, a finding's sufficiency adds a warning to it. */
196
204
  readonly sufficiencyThreshold: number;
197
205
  }
@@ -459,6 +467,7 @@ export const planAudit = (
459
467
  requests: loads.reduce((sum, load) => sum + load.requests, 0),
460
468
  tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
461
469
  ...(price === undefined ? {} : { price }),
470
+ threshold: config.threshold,
462
471
  sufficiencyThreshold: config.sufficiencyThreshold,
463
472
  };
464
473
  });
@@ -509,9 +518,13 @@ export const executeAudit = (
509
518
  deferred,
510
519
  sampled,
511
520
  }: FilePlan) {
521
+ // What a rule's appendState gets: the file as written, not as Jev reads it.
522
+ const asWritten = { path: file.path, contents: original.join("\n") };
512
523
  const { probabilities, linter, matchers }: Judged = isEmpty(pending)
513
524
  ? { probabilities: {}, linter: {} }
514
- : yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
525
+ : yield* jev
526
+ .judge(file.lines, rulesOf(pending), Object.keys(sampled), asWritten)
527
+ .pipe(inFile(file));
515
528
  if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
516
529
  const judged: Record<RuleId, Judgment> = { ...kept };
517
530
  for (const [id, probability] of Object.entries(probabilities)) {
@@ -549,7 +562,7 @@ export const executeAudit = (
549
562
  readonly blocked: Blocked | undefined;
550
563
  } = isEmpty(flagged)
551
564
  ? { places: {}, blocked: undefined }
552
- : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
565
+ : yield* jev.locate(file.lines, rulesOf(flagged), asWritten).pipe(
553
566
  Effect.map((places) => ({ places, blocked: undefined })),
554
567
  Effect.catchTag("JevBlocked", ({ ray }) =>
555
568
  Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
@@ -581,11 +594,9 @@ export const executeAudit = (
581
594
  section: judgment.section,
582
595
  ...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
583
596
  excerpt: excerptOf(original, judgment.section),
584
- ...(judgment.sufficiency !== undefined &&
585
- judgment.sufficiency < plan.sufficiencyThreshold
586
- ? { insufficiency: 1 - judgment.sufficiency }
587
- : {}),
597
+ ...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
588
598
  probability: judgment.probability,
599
+ threshold: k.threshold,
589
600
  level: k.rule.level ?? "error",
590
601
  },
591
602
  ]